From 35d00a27fd23f2dc94c7dc0f0064fe50360d0861 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Mon, 18 May 2026 12:41:37 +0200 Subject: [PATCH] feat(AADSyncRuleCount): add report-only rule-count DSC resource New class-based DSC resource that compares the expected number of AAD Connect sync rules against the current state and reports drift. Scoped per connector via ConnectorName, or across all connectors when ConnectorName is empty or '*'. Set() throws on drift; the resource never attempts to remediate count differences because that is not meaningful. Also registers the resource in the manifest, adds a basic example, and updates CHANGELOG and the memory bank. --- .memory-bank/activeContext.md | 25 +++-- .memory-bank/progress.md | 25 +++-- CHANGELOG.md | 9 ++ source/AADConnectDsc.psd1 | 3 +- source/Classes/AADSyncRuleCount.ps1 | 105 ++++++++++++++++++ .../1-AADSyncRuleCount_Basic.ps1 | 29 +++++ 6 files changed, 173 insertions(+), 23 deletions(-) create mode 100644 source/Classes/AADSyncRuleCount.ps1 create mode 100644 source/Examples/Resources/AADSyncRuleCount/1-AADSyncRuleCount_Basic.ps1 diff --git a/.memory-bank/activeContext.md b/.memory-bank/activeContext.md index d1c0439..4ae331a 100644 --- a/.memory-bank/activeContext.md +++ b/.memory-bank/activeContext.md @@ -4,23 +4,26 @@ _Last updated: 2026-05-18 (UTC). Owner: software-engineer agent._ ## Current Focus -No active feature work. Repository is on eature/RuleCountResource but the -branch is identical to `main` (HEAD `9033220`); the name appears to be a -placeholder for a future `AADSyncRuleCount` / rule-inventory resource that -has not been started. +Shipped `AADSyncRuleCount` DSC resource on branch +`ai/aadsyncrulecount-resource` (replaces the reserved +`feature/RuleCountResource` placeholder). Report-only resource: compares +expected vs. actual sync-rule count per connector (or across all +connectors when `ConnectorName` is empty/`*`), throws from `Set()` on +drift, never remediates. New event IDs 1100/1101/1102. Most recent shipped change (PR #30, May 2026): refreshed `build.ps1`, `Resolve-Dependency.ps1` and `RequiredModules.psd1` to current Sampler -conventions. Captured in `CHANGELOG.md` under `[Unreleased]`. +conventions. ## Open Decisions -- **RuleCountResource scope** — undecided. Likely a read-only/inventory DSC - resource or a public function returning sync-rule counts per connector. No - spec drafted; do not implement speculatively. -- **Next release cut** — `[Unreleased]` only contains a build-system refresh. - Decide whether to ship as `0.5.1` (patch) or fold into the next feature - release. Default: patch, since user-visible surface is unchanged. +- **Unit tests for AADSyncRuleCount** — not yet written. ADSync cmdlets + cannot run on a build agent, so tests must mock `Get-ADSyncRule`. Mirror + the pattern used by existing class tests (none exist yet under `tests/` + for the resource classes, only `tests/QA/module.tests.ps1`). +- **Next release cut** — `[Unreleased]` now contains the new resource + plus the earlier build-system refresh; bump to `0.6.0` (minor) for the + new public surface. ## Next Steps (when work resumes) diff --git a/.memory-bank/progress.md b/.memory-bank/progress.md index f6b51c6..b52df62 100644 --- a/.memory-bank/progress.md +++ b/.memory-bank/progress.md @@ -8,21 +8,24 @@ shipped work. Older milestones are summarised; full detail lives in - **Released**: ``0.5.0`` (2025-10-17) — published to PowerShell Gallery via the standard Sampler/Azure Pipelines flow. -- **In ``[Unreleased]``**: Build-system refresh only (``build.ps1``, - ``Resolve-Dependency.ps1``, ``RequiredModules.psd1`` aligned to current - Sampler). No user-visible behaviour change. -- **Branch**: ``feature/RuleCountResource`` exists but contains zero commits - beyond ``main``. Treat as a name reservation, not work-in-progress. -- **Open work**: none active. Remaining doc-quality items (markdown lint, - link validation) are tracked below. +- **In ``[Unreleased]``**: build-system refresh + new ``AADSyncRuleCount`` + DSC resource (report-only rule-count verifier, per-connector or global). +- **Branch**: ``ai/aadsyncrulecount-resource`` — implements the previously + reserved ``feature/RuleCountResource`` scope. +- **Open work**: unit tests for ``AADSyncRuleCount`` and doc-quality + items below. + +## Shipped (recent) + +- 2026-05-18 — Added ``AADSyncRuleCount`` DSC resource. Throws from + ``Set()`` on count drift, never remediates. Event IDs 1100/1101/1102. ## Remaining Tasks -1. **Markdown lint sweep** across ``docs/`` — minor formatting drift. -2. **Link validation** for internal/external links in ``README.md`` and +1. **Unit tests** for ``AADSyncRuleCount`` (mock ``Get-ADSyncRule``). +2. **Markdown lint sweep** across ``docs/`` — minor formatting drift. +3. **Link validation** for internal/external links in ``README.md`` and ``docs/``. -3. **Decide RuleCountResource scope** before any code lands on the - feature branch. ## Recent Releases (newest first) diff --git a/CHANGELOG.md b/CHANGELOG.md index 970dbd6..3ea91c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New DSC resource `AADSyncRuleCount` that verifies the number of + Azure AD Connect synchronization rules against an expected value. + The resource is report-only: it never attempts to remediate count + drift and instead throws a descriptive error so the LCM marks the + configuration as failed. Scope is per-connector via `ConnectorName`, + or across all connectors when `ConnectorName` is empty or `*`. + ### Changed - Updated module dependencies in `RequiredModules.psd1`. diff --git a/source/AADConnectDsc.psd1 b/source/AADConnectDsc.psd1 index e6443c5..f0d1bb8 100644 --- a/source/AADConnectDsc.psd1 +++ b/source/AADConnectDsc.psd1 @@ -79,7 +79,8 @@ # DSC resources to export from this module DscResourcesToExport = @( 'AADSyncRule', - 'AADConnectDirectoryExtensionAttribute' + 'AADConnectDirectoryExtensionAttribute', + 'AADSyncRuleCount' ) # List of all modules packaged with this module diff --git a/source/Classes/AADSyncRuleCount.ps1 b/source/Classes/AADSyncRuleCount.ps1 new file mode 100644 index 0000000..36dc13e --- /dev/null +++ b/source/Classes/AADSyncRuleCount.ps1 @@ -0,0 +1,105 @@ +[DscResource()] +class AADSyncRuleCount +{ + <# + .SYNOPSIS + Verifies the number of Azure AD Connect synchronization rules. + + .DESCRIPTION + Read-only / report-only DSC resource that compares the expected + number of AAD Connect sync rules against the current state and + reports a drift if they differ. The resource never attempts to + remediate drift because creating or removing rules to match a + count is not a meaningful operation; the user must investigate + and reconcile manually. + + Use `ConnectorName` to scope the count to a specific connector. + Leave `ConnectorName` empty (or set it to `*`) to count every + sync rule across all connectors. + #> + + # `ConnectorName` is the key. Empty string or '*' means "all connectors". + [DscProperty(Key = $true)] + [string]$ConnectorName + + [DscProperty(Mandatory = $true)] + [uint32]$RuleCount + + [DscProperty(NotConfigurable)] + [uint32]$CurrentRuleCount + + [bool]Test() + { + $currentState = $this.Get() + $scope = $this.GetScopeDescription() + + if ($currentState.CurrentRuleCount -eq $this.RuleCount) + { + Write-Verbose -Message "AADSyncRuleCount: $scope has the expected $($this.RuleCount) sync rule(s)." + $this.TryWriteEventLog('Information', 1100, "AADSyncRuleCount in desired state for $scope (count=$($this.RuleCount)).") + return $true + } + + Write-Verbose -Message "AADSyncRuleCount: $scope has $($currentState.CurrentRuleCount) sync rule(s) but expected $($this.RuleCount)." + $this.TryWriteEventLog('Warning', 1101, "AADSyncRuleCount drift for $scope. Expected=$($this.RuleCount); Current=$($currentState.CurrentRuleCount).") + return $false + } + + [AADSyncRuleCount]Get() + { + $currentState = [AADSyncRuleCount]::new() + $currentState.ConnectorName = $this.ConnectorName + $currentState.RuleCount = $this.RuleCount + + $rules = if ([string]::IsNullOrEmpty($this.ConnectorName) -or $this.ConnectorName -eq '*') + { + Get-ADSyncRule + } + else + { + Get-ADSyncRule -ConnectorName $this.ConnectorName + } + + # Get-ADSyncRule may return $null when no rules match; coerce to 0. + $currentState.CurrentRuleCount = if ($null -eq $rules) { 0 } else { @($rules).Count } + + return $currentState + } + + [void]Set() + { + # This resource is report-only. Adjusting the rule count automatically + # is not safe and not meaningful — the operator must investigate which + # rules are missing or extra. Throw a clear error so the LCM marks the + # configuration as failed. + + $currentState = $this.Get() + $scope = $this.GetScopeDescription() + $message = "AADSyncRuleCount drift detected for $scope. Expected $($this.RuleCount) sync rule(s) but found $($currentState.CurrentRuleCount). This resource does not remediate count drift; investigate the AAD Connect configuration manually." + + $this.TryWriteEventLog('Error', 1102, $message) + throw $message + } + + hidden [string]GetScopeDescription() + { + if ([string]::IsNullOrEmpty($this.ConnectorName) -or $this.ConnectorName -eq '*') + { + return "all connectors" + } + + return "connector '$($this.ConnectorName)'" + } + + hidden [void]TryWriteEventLog([string]$EventType, [int]$EventId, [string]$Message) + { + try + { + Write-AADConnectEventLog -EventType $EventType -EventId $EventId -Message $Message -ConnectorName $this.ConnectorName + } + catch + { + Write-Verbose -Message "Failed to write event log entry: $($_.Exception.Message)" + } + } +} diff --git a/source/Examples/Resources/AADSyncRuleCount/1-AADSyncRuleCount_Basic.ps1 b/source/Examples/Resources/AADSyncRuleCount/1-AADSyncRuleCount_Basic.ps1 new file mode 100644 index 0000000..560cd72 --- /dev/null +++ b/source/Examples/Resources/AADSyncRuleCount/1-AADSyncRuleCount_Basic.ps1 @@ -0,0 +1,29 @@ +<# +.EXAMPLE 1 + +Verifies that the 'contoso.com' connector has exactly 42 sync rules. +If the actual count differs, the resource throws an error during Set +and the LCM marks the configuration as failed. The resource never +attempts to add or remove rules to reach the expected count. +#> + +configuration Example_AADSyncRuleCount_Basic +{ + Import-DscResource -ModuleName AADConnectDsc + + node localhost + { + AADSyncRuleCount 'ContosoRuleCount' + { + ConnectorName = 'contoso.com' + RuleCount = 42 + } + + # Verify the total number of rules across all connectors. + AADSyncRuleCount 'TotalRuleCount' + { + ConnectorName = '*' + RuleCount = 168 + } + } +}