Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion powershell/Maester.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
'Get-MailAuthenticationRecord', 'Get-MtAdminPortalUrl', 'Get-MtAuthenticationMethodPolicyConfig',
'Get-MtAzureManagementGroup', 'Get-MtConditionalAccessPolicy', 'Get-MtExo', 'Get-MtExoThreatPolicyMalware',
'Get-MtGraphScope', 'Get-MtGroupMember', 'Get-MtHtmlReport', 'Get-MtLicenseInformation', 'Get-MtMaesterApp', 'Get-MtRole',
'Get-MtRoleMember', 'Get-MtSafeMarkdown', 'Get-MtSession', 'Get-MtTestInventory', 'Get-MtUser',
'Get-MtRoleMember', 'Get-MtSafeMarkdown', 'Get-MtSession', 'Get-MtSessionLicense', 'Get-MtTestInventory', 'Get-MtUser',
'Get-MtUserAuthenticationMethod', 'Get-MtUserAuthenticationMethodInfoByType', 'Import-MtMaesterResult',
'Install-MaesterTests', 'Invoke-Maester', 'Invoke-MtAzureRequest', 'Invoke-MtAzureResourceGraphRequest',
'Invoke-MtGraphRequest', 'Invoke-MtGraphSecurityQuery', 'Merge-MtMaesterResult', 'New-MtMaesterApp', 'Resolve-SPFRecord',
Expand Down
1 change: 1 addition & 0 deletions powershell/Maester.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ $__MtSession = @{
DataverseApiBase = $null # Resolved Dataverse OData API base URL (e.g. https://org123.api.crm.dynamics.com/api/data/v9.2)
DataverseResourceUrl = $null # Dataverse resource URL for token acquisition (e.g. https://org123.crm.dynamics.com)
DataverseEnvironmentId = $null # Environment identifier for display (e.g. org123.crm.dynamics.com)
Licenses = @{} # Pre-fetched license map keyed by product name, populated by Initialize-MtSession
}
New-Variable -Name __MtSession -Value $__MtSession -Scope Script -Force

Expand Down
1 change: 1 addition & 0 deletions powershell/internal/Clear-ModuleVariable.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
$__MtSession.TestResultDetail = @{}
$__MtSession.MaesterConfig = $null
$__MtSession.AdminPortalUrl = @{}
$__MtSession.Licenses = @{}
Clear-MtDnsCache
Clear-MtExoCache
$__MtSession.AIAgentInfo = $null
Expand Down
3 changes: 3 additions & 0 deletions powershell/internal/Get-MtRoleInfo.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ function Get-MtRoleInfo {

if ([string]::IsNullOrWhiteSpace($RoleName)) { return $null }

# Guard against the role definitions not being loaded (e.g., if module scope was lost).
if ($null -eq $script:MtRoles) { return $null }

if ($script:MtRoles.ContainsKey($RoleName)) {
return $script:MtRoles[$RoleName]
}
Expand Down
15 changes: 15 additions & 0 deletions powershell/internal/Initialize-MtSession.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,19 @@
}

$__MtSession.AdminPortalUrl = Get-MtAdminPortalUrl -Environment $environment

# Pre-fetch all license products into the session cache so that BeforeDiscovery blocks
# in test files pay zero Graph API cost when calling Get-MtLicenseInformation.
# ExoLicenseCount is intentionally excluded here as it returns a number (seat count)
# rather than a product string and is not used for skip-gating.
$licenseProducts = @('EntraID', 'EntraWorkloadID', 'Eop', 'ExoDlp', 'Mdo', 'MdoV2',
'AdvAudit', 'DefenderXDR', 'CustomerLockbox', 'Intune')
try {
foreach ($product in $licenseProducts) {
$__MtSession.Licenses[$product] = Get-MtLicenseInformation -Product $product
}
Write-Verbose "License information pre-fetched for $($licenseProducts.Count) products."
} catch {
Write-Verbose "License pre-fetch skipped (tenant not reachable or not connected to Graph): $_"
}
}
43 changes: 25 additions & 18 deletions powershell/public/Get-MtLicenseInformation.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,26 @@ function Get-MtLicenseInformation {

begin {
# Get all subscribed SKUs once to optimize performance.
$SKUs = Invoke-MtGraphRequest -RelativeUri 'subscribedSkus' | Where-Object { $_.capabilityStatus -eq 'Enabled' }
$ServicePlans = $SKUs | Select-Object -ExpandProperty servicePlans | Select-Object -ExpandProperty servicePlanId | Sort-Object -Unique
# Skip the Graph call entirely if the session cache already has this product.
# (Initialize-MtSession pre-populates $__MtSession.Licenses for all products.)
$SKUs = $null
$ServicePlans = $null
}

process {
# Return from session cache when available (populated by Initialize-MtSession).
if ($__MtSession.Licenses.ContainsKey($Product)) {
Write-Verbose "Returning cached license information for $Product"
return $__MtSession.Licenses[$Product]
}

# Lazy-load SKU data only when a cache miss requires it.
if ($null -eq $SKUs) {
$SKUs = Invoke-MtGraphRequest -RelativeUri 'subscribedSkus' | Where-Object { $_.capabilityStatus -eq 'Enabled' }
$ServicePlans = $SKUs | Select-Object -ExpandProperty servicePlans | Select-Object -ExpandProperty servicePlanId | Sort-Object -Unique
}

$LicenseType = $null
switch ($Product) {
'EntraID' {
Write-Verbose 'Retrieving license information for Entra ID'
Expand All @@ -45,7 +60,6 @@ function Get-MtLicenseInformation {
$LicenseType = 'Free'
}
Write-Information "The license type for Entra ID is $LicenseType"
return $LicenseType
break
}
'EntraWorkloadID' {
Expand All @@ -58,7 +72,6 @@ function Get-MtLicenseInformation {
$LicenseType = $null
}
Write-Information "The license type for Entra ID is $LicenseType"
return $LicenseType
break
}
'Eop' {
Expand All @@ -76,7 +89,6 @@ function Get-MtLicenseInformation {
}
}
Write-Information "The license type for Exchange Online Protection is $LicenseType"
return $LicenseType
break
}
'ExoDlp' {
Expand All @@ -99,7 +111,6 @@ function Get-MtLicenseInformation {
}
}
Write-Information "The license type for Exchange Online DLP is $LicenseType"
return $LicenseType
break
}
'Mdo' {
Expand All @@ -118,7 +129,6 @@ function Get-MtLicenseInformation {
}
}
Write-Information "The license type for Defender for Office is $LicenseType"
return $LicenseType
break
}
'MdoV2' {
Expand All @@ -135,7 +145,6 @@ function Get-MtLicenseInformation {
$LicenseType = 'EOP' # Exchange Online Protection / EOP_ENTERPRISE (326e2b78-9d27-42c9-8509-46c827743a17)
}
Write-Information "The license type for Defender for Office is $LicenseType"
return $LicenseType
break
}
'AdvAudit' {
Expand All @@ -153,7 +162,6 @@ function Get-MtLicenseInformation {
}
}
Write-Information "The license type for Advanced Audit is $LicenseType"
return $LicenseType
break
}
'ExoLicenseCount' {
Expand Down Expand Up @@ -189,7 +197,7 @@ function Get-MtLicenseInformation {
}

Write-Information "Total Exchange Online licenses: $TotalLicenses"
return $TotalLicenses
$LicenseType = $TotalLicenses
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to remember how this function works: should $LicenseType be returning the total count of licenses? That sounds like a mismatch, but I could be wrong.

break
}
'DefenderXDR' {
Expand All @@ -208,7 +216,6 @@ function Get-MtLicenseInformation {
}
}
Write-Information 'The tenant is licensed for Defender XDR'
return $LicenseType
break
}
'CustomerLockbox' {
Expand All @@ -224,13 +231,10 @@ function Get-MtLicenseInformation {
$LicenseType = $null
}
Write-Information "The license type for Customer Lockbox is $LicenseType"
return $LicenseType
break
}
"Intune" {
Write-Verbose "Retrieving license SKUs for Intune"
$subscribedSkus = Invoke-MtGraphRequest -RelativeUri "subscribedSkus" | Where-Object {$_.capabilityStatus -eq 'Enabled'}
$uniqueServicePlans = $subscribedSkus.servicePlans.servicePlanId | Sort-Object -Unique
$requiredServicePlans = @(
# https://learn.microsoft.com/en-us/entra/identity/users/licensing-service-plan-reference
"c1ec4a95-1f05-45b3-a911-aa3fa01094f5", # INTUNE_A (Microsoft Intune Plan 1)
Expand All @@ -244,16 +248,19 @@ function Get-MtLicenseInformation {

)
$LicenseType = $null
foreach($servicePlan in $requiredServicePlans){
if($servicePlan -in $uniqueServicePlans){
foreach ($servicePlan in $requiredServicePlans) {
if ($servicePlan -in $ServicePlans) {
$LicenseType = "Intune"
}
}
Write-Information "The tenant is licensed for Intune"
return $LicenseType
Break
break
}
Default {}
}

# Store in session cache so subsequent calls (including BeforeDiscovery blocks) pay no API cost.
$__MtSession.Licenses[$Product] = $LicenseType
return $LicenseType
}
}
42 changes: 42 additions & 0 deletions powershell/public/Get-MtSessionLicense.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
function Get-MtSessionLicense {
Comment thread
SamErde marked this conversation as resolved.
Comment thread
Mynster9361 marked this conversation as resolved.
<#
.SYNOPSIS
Returns the pre-fetched license map for the current tenant session.

.DESCRIPTION
Returns a hashtable of all license products evaluated for the current tenant.
The map is populated once by Initialize-MtSession (called at the start of Invoke-Maester)
so that BeforeDiscovery blocks in test files can gate tests on license availability
without making additional Graph API calls.

Keys match the -Product parameter of Get-MtLicenseInformation.
Use this function in BeforeDiscovery blocks instead of calling Get-MtLicenseInformation
per-product.

.EXAMPLE
BeforeDiscovery {
$Licenses = Get-MtSessionLicense
}

Describe "Maester/Entra" -Tag "Maester", "Entra" {
It "MT.XXXX: ..." -Tag "MT.XXXX" -Skip:($Licenses.EntraID -eq "Free") {
Test-MtSomeP1Feature | Should -Be $true
}
It "MT.XXXY: ..." -Tag "MT.XXXY" -Skip:($Licenses.EntraID -ne "P2") {
Test-MtSomeP2Feature | Should -Be $true
}
It "MT.XXXZ: ..." -Tag "MT.XXXZ" -Skip:($null -eq $Licenses.Intune) {
Test-MtSomeIntuneFeature | Should -Be $true
}
}

.LINK
https://maester.dev/docs/commands/Get-MtSessionLicense
#>
[OutputType([hashtable])]
[CmdletBinding()]
param()

Write-Verbose "Returning session license cache with $($__MtSession.Licenses.Count) products."
return $__MtSession.Licenses
}
60 changes: 58 additions & 2 deletions powershell/public/Invoke-Maester.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@

Connect to all tested services and run all tests, including the long-running and preview tests.

.EXAMPLE
Invoke-Maester -AutoFilterLicense

Runs tests and automatically skips any test that requires a license the tenant does not have.
For example, on a tenant with only a Business Premium license, tests requiring Entra ID P2,
Defender XDR, or Advanced Audit will be excluded from the run.

.LINK
https://maester.dev/docs/commands/Invoke-Maester
#>
Expand Down Expand Up @@ -206,7 +213,14 @@

# The root directory for configuration drift tracking.
[Parameter(HelpMessage = 'Specify drift root directory, see https://maester.dev/docs/tests/MT.1060')]
[string] $DriftRoot
[string] $DriftRoot,

# Automatically exclude tests that require licenses not present in the tenant.
# When set, Maester queries the tenant license information at startup and adds the
# appropriate License-* tags to ExcludeTag so unlicensed tests are skipped cleanly.
# This requires a Graph connection and is silently ignored when not connected.
[Parameter(HelpMessage = 'Skip tests that require licenses the tenant does not have.')]
[switch] $AutoFilterLicense
Comment on lines +218 to +223
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should automatically exclude tests that the tenant is not licensed for and not require a parameter for this.

The alternative should be opt-in: use a switch (e.g. IgnoreLicense) to perform all tests regardless of what licenses are detected.

)

function GetDefaultFileName() {
Expand Down Expand Up @@ -324,9 +338,51 @@
Test-MtContext -SendMail:$isMail -SendTeamsMessage:$isTeamsChannelMessage | Out-Null
}

# Initialize MtSession after Graph connected.
# Initialize MtSession after Graph connected (also pre-fetches license information).
Initialize-MtSession

# If -AutoFilterLicense is set, exclude tests whose required license is absent in the tenant.
if ($AutoFilterLicense.IsPresent) {
$tenantLicenses = Get-MtSessionLicense
if ($tenantLicenses.Count -gt 0) {
$licenseExclusions = [System.Collections.Generic.List[string]]::new()

# Entra ID tiers: Free excludes P1 + P2 + Governance; P1 excludes P2 + Governance
switch ($tenantLicenses['EntraID']) {
'Free' {
$licenseExclusions.AddRange([string[]]@('License-EntraP1', 'License-EntraP2', 'License-EntraGovernance'))
}
'P1' {
$licenseExclusions.AddRange([string[]]@('License-EntraP2', 'License-EntraGovernance'))
}
}

# Binary (licensed / not licensed) products
$binaryProducts = @{
'EntraWorkloadID' = 'License-EntraWorkloadID'
'Eop' = 'License-Eop'
'ExoDlp' = 'License-ExoDlp'
'Mdo' = 'License-Mdo'
'AdvAudit' = 'License-AdvAudit'
'DefenderXDR' = 'License-DefenderXDR'
'CustomerLockbox' = 'License-CustomerLockbox'
'Intune' = 'License-Intune'
}
foreach ($entry in $binaryProducts.GetEnumerator()) {
if ($null -eq $tenantLicenses[$entry.Key]) {
$licenseExclusions.Add($entry.Value)
}
}

if ($licenseExclusions.Count -gt 0) {
$ExcludeTag = @($ExcludeTag | Where-Object { $_ }) + $licenseExclusions
Comment on lines +351 to +378
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please align this tag structure with the way that the optional Severity:{Severity} tags are written. For example, License:Intune.

See https://maester.dev/docs/configuration/severity-levels.

Write-Verbose "AutoFilterLicense: excluding tags $($licenseExclusions -join ', ')"
}
Comment on lines +344 to +380
} else {
Write-Verbose 'AutoFilterLicense: license data not available, skipping auto-filter.'
}
}

if ($isWebUri) {
# Check if TeamChannelWebhookUri is a valid URL.
$urlPattern = '^(https)://[^\s/$.?#].[^\s]*$'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,19 @@
$testResult = "The following conditional access policies are scoped to all users but don't exclude the directory/OnPremises synchronization accounts:`n`n"

try {
$DirectorySynchronizationAccountsRole = Get-MtRoleInfo -RoleName 'DirectorySynchronizationAccounts'
$OnPremisesDirectorySyncAccountRole = Get-MtRoleInfo -RoleName 'OnPremisesDirectorySyncAccount'
# Role template IDs are stable Microsoft-defined GUIDs; use them as fallback
# when Get-MtRoleInfo cannot resolve via the module role definitions cache.
# Variables kept as strings so they work correctly in -in comparisons against
# CA policy conditions (includeRoles/excludeRoles contain GUID strings).
$dirSyncInfo = Get-MtRoleInfo -RoleName 'DirectorySynchronizationAccounts'
$DirectorySynchronizationAccountsRole = if ($null -ne $dirSyncInfo) { $dirSyncInfo.ToString() } else { 'd29b2b05-8046-44ba-8758-1e26182fcf32' }

$onPremInfo = Get-MtRoleInfo -RoleName 'OnPremisesDirectorySyncAccount'
$OnPremisesDirectorySyncAccountRole = if ($null -ne $onPremInfo) { $onPremInfo.ToString() } else { 'a92aed5d-d78a-4d16-b381-09adb37eb3b0' }

$Members = @()
$Members += Get-MtRoleMember -RoleId $DirectorySynchronizationAccountsRole
$Members += Get-MtRoleMember -RoleId $OnPremisesDirectorySyncAccountRole
$Members += Get-MtRoleMember -RoleId ([guid]$DirectorySynchronizationAccountsRole)
$Members += Get-MtRoleMember -RoleId ([guid]$OnPremisesDirectorySyncAccountRole)
$Members = @($Members | Where-Object { $null -ne $_ })

if ( $Members.Count -eq 0 ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@

return $result
} catch {
Add-MtTestResultDetail -Error $_ -GraphObjectType ConditionalAccess
Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ -GraphObjectType ConditionalAccess
return $false
}
}
2 changes: 1 addition & 1 deletion powershell/public/maester/entra/Test-MtCaMfaForGuest.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
Add-MtTestResultDetail -Result $testResult -GraphObjects $policiesResult -GraphObjectType ConditionalAccess
return $result
} catch {
Add-MtTestResultDetail -Error $_ -GraphObjectType ConditionalAccess
Add-MtTestResultDetail -SkippedBecause Error -SkippedError $_ -GraphObjectType ConditionalAccess
return $false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
[OutputType([bool])]
param ()

if ( ( Get-MtLicenseInformation EntraID ) -ne "P2" ) {
if ( ( Get-MtLicenseInformation EntraID ) -notin 'P2', 'Governance') {
Add-MtTestResultDetail -SkippedBecause NotLicensedEntraIDP2
return $null
}
Expand Down
Loading
Loading