diff --git a/Config/BaselineStandards/Templates/ConditionalAccessTemplatePackage.json b/Config/BaselineStandards/Templates/ConditionalAccessTemplatePackage.json new file mode 100644 index 0000000000000..f899060e31699 --- /dev/null +++ b/Config/BaselineStandards/Templates/ConditionalAccessTemplatePackage.json @@ -0,0 +1,67 @@ +{ + "name": "ConditionalAccessTemplatePackage", + "label": "Conditional Access Template Package", + "cat": "Templates", + "tag": ["HighImpact"], + "impact": "High Impact", + "helpText": "Deploys and drift-checks EVERY Conditional Access template tagged with the selected package. Membership is resolved fresh on every run: tag a new template with the package and it joins this baseline automatically, untag it and it leaves. All templates in the package share the deployment state configured here - add a template individually instead when it needs a different state.", + "executiveText": "Deploys a complete, named bundle of sign-in security policies in one step - for example a full zero-trust Conditional Access set - and keeps every policy in the bundle enforced as the bundle evolves.", + "recommendedBy": ["CIPP"], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"], + "secureScoreImpact": 30, + "instanceIdentity": "caTemplatePackage", + "multiple": true, + "package": { + "memberStandard": "ConditionalAccessTemplate", + "memberVariable": "caTemplate", + "variable": "caTemplatePackage", + "templatePartition": "CATemplate" + }, + "variables": { + "caTemplatePackage": { + "type": "autoComplete", + "label": "Select a package of Conditional Access Templates", + "api": { + "url": "/api/ListCATemplates?mode=Tag", + "labelField": "label", + "valueField": "value", + "queryKey": "ListCATemplates-tags", + "showRefresh": true + } + }, + "state": { + "type": "autoComplete", + "label": "What state should we deploy these templates in?", + "options": [ + { + "label": "Do not change state", + "value": "donotchange" + }, + { + "label": "Report only", + "value": "enabledForReportingButNotEnforced" + }, + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ], + "default": "enabledForReportingButNotEnforced", + "recommended": "enabled" + }, + "disableSD": { + "type": "switch", + "label": "Disable Security Defaults when deploying policy", + "default": false + }, + "createGroups": { + "type": "switch", + "label": "Create groups if they do not exist", + "default": false + } + } +} diff --git a/Config/BaselineStandards/Templates/IntuneTemplatePackage.json b/Config/BaselineStandards/Templates/IntuneTemplatePackage.json new file mode 100644 index 0000000000000..2f82f31513ed9 --- /dev/null +++ b/Config/BaselineStandards/Templates/IntuneTemplatePackage.json @@ -0,0 +1,106 @@ +{ + "name": "IntuneTemplatePackage", + "label": "Intune Template Package", + "cat": "Templates", + "tag": ["HighImpact"], + "impact": "High Impact", + "helpText": "Deploys and drift-checks EVERY Intune template tagged with the selected package. Membership is resolved fresh on every run: tag a new template with the package and it joins this baseline automatically, untag it and it leaves. All templates in the package share the assignment options configured here - add a template individually instead when it needs different options.", + "executiveText": "Deploys a complete, named bundle of device management configurations in one step - for example an entire Windows security baseline - and keeps every policy in the bundle enforced as the bundle evolves.", + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "secureScoreImpact": 20, + "instanceIdentity": "intuneTemplatePackage", + "multiple": true, + "package": { + "memberStandard": "IntuneTemplate", + "memberVariable": "intuneTemplate", + "variable": "intuneTemplatePackage", + "templatePartition": "IntuneTemplate" + }, + "variables": { + "intuneTemplatePackage": { + "type": "autoComplete", + "label": "Select a package of Intune Templates", + "api": { + "url": "/api/ListIntuneTemplates?mode=Tag", + "labelField": "label", + "valueField": "value", + "queryKey": "ListIntuneTemplates-tags", + "showRefresh": true + } + }, + "assignTo": { + "type": "autoComplete", + "label": "Who should these templates be assigned to?", + "options": [ + { + "label": "Do not assign", + "value": "On" + }, + { + "label": "Assign to all users", + "value": "allLicensedUsers" + }, + { + "label": "Assign to all devices", + "value": "AllDevices" + }, + { + "label": "Assign to all users and devices", + "value": "AllDevicesAndUsers" + }, + { + "label": "Assign to Custom Group", + "value": "customGroup" + } + ], + "default": "On" + }, + "customGroup": { + "type": "textField", + "label": "Custom group name (if assigning to a custom group; wildcards allowed)", + "default": "" + }, + "excludeGroup": { + "type": "textField", + "label": "Exclude groups (comma-separated, wildcards allowed)", + "default": "" + }, + "assignmentFilter": { + "type": "textField", + "label": "Assignment filter name (optional, wildcards allowed)", + "default": "" + }, + "assignmentFilterType": { + "type": "autoComplete", + "label": "Assignment filter mode", + "options": [ + { + "label": "Include - assign to devices matching the filter", + "value": "include" + }, + { + "label": "Exclude - assign to devices NOT matching the filter", + "value": "exclude" + } + ], + "default": "include" + }, + "verifyAssignments": { + "type": "switch", + "label": "Verify policy assignments", + "default": false + }, + "levenshteinDistance": { + "type": "number", + "label": "Fuzzy match distance (0 = exact name match only; values above 5 can match unrelated policies)", + "default": 0 + } + } +} diff --git a/Config/openapi.json b/Config/openapi.json index af5479f7a5e1c..93b7120c5186e 100644 --- a/Config/openapi.json +++ b/Config/openapi.json @@ -667,9 +667,9 @@ "tags": [ "Tenant > Standards" ], - "description": "Creates or updates a baseline. There is no baseline blob: the Baselines\ndelta rows (design doc §4.1) are the editable source of truth for every standard's\nconfiguration, and the BaselineRollouts row (§12.2) holds the baseline-level data -\nname, description, exclusions, alert destinations, and the ordered stage definitions.\nBaselines are reconstructed from those rows on read.", + "description": "Creates or updates a baseline. There is no baseline blob: the Baselines\ndelta rows (design doc §4.1) are the editable source of truth for every standard's\nconfiguration, and the BaselineRollouts row (§12.2) holds the baseline-level data -\nname, description, exclusions, alert destinations, and the ordered stage definitions.\nBaselines are reconstructed from those rows on read. The actual write lives in\nNew-CIPPBaseline, shared with the community-repo import.", "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { @@ -722,9 +722,9 @@ "type": "string" } }, - "required": [ - "templateName" - ] + "additionalProperties": true, + "x-cipp-passthrough": true, + "description": "This endpoint forwards the request body onward rather than reading a fixed set of fields. The properties listed here are the ones it is known to read; others may be accepted." } } } @@ -764,7 +764,10 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.Standards.ReadWrite", + "x-cipp-reads-via": [ + "New-CIPPBaseline" + ] } }, "/api/AddBPATemplate": { @@ -15079,7 +15082,8 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.AppSettings.ReadWrite" + "x-cipp-role": "CIPP.AppSettings.ReadWrite", + "x-cipp-any-tenant": true } }, "/api/ExecBreachSearch": { @@ -16301,6 +16305,7 @@ "SetBranch", "SetTemplateTypes", "Update", + "UploadBaseline", "UploadScript", "UploadTemplate" ] @@ -16315,7 +16320,8 @@ "type": "string" }, "GUID": { - "type": "string" + "type": "string", + "description": "A baseline is not a templates-table row: Export-CIPPBaselineTemplate assembles the portable set - the BaselineTemplate file plus one standard template file per referenced CA/Intune template (packages expanded to their current members). Related templates are separate files, exactly the shape UploadTemplate writes, so they import through the untouched path." }, "Id": { "type": "string" @@ -18595,6 +18601,9 @@ } } }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, "401": { "description": "Unauthorized - invalid or missing bearer token" }, @@ -30165,6 +30174,53 @@ "x-cipp-role": "CIPP.SuperAdmin.ReadWrite" } }, + "/api/ExecSamSecretStatus": { + "post": { + "summary": "Reports whether the stored SAM application secret is usable yet.", + "operationId": "ExecSamSecretStatus", + "tags": [ + "CIPP > Setup" + ], + "description": "The setup wizard creates a client secret on one step and uses it on the next, but Entra\ncan take several minutes to replicate a newly created secret. Until it has, every token\nrequest fails with AADSTS7000215 even though the value CIPP holds is correct. This lets\nthe wizard wait on that instead of failing the user after they have already signed in.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.AppSettings.ReadWrite", + "x-cipp-any-tenant": true + } + }, "/api/ExecScheduleForwardingVacation": { "post": { "summary": "ExecScheduleForwardingVacation", @@ -38369,7 +38425,7 @@ "tags": [ "CIPP > Settings" ], - "description": "Returns the report branding: colours, logo and cover images, footer and watermark text.\n\nThis used to ride along inside ListUserSettings, which meant every page load carried every\nuploaded cover as an inline data URL — megabytes of images fetched to render a settings\nmenu. Worse, hydrating branding also ran the legacy-image migration, so a GET issued on\nevery page load wrote the BrandingSettings row back from a snapshot it had read moments\nearlier, and any upload that landed in between was overwritten.\n\nBranding therefore has its own endpoint, fetched by the things that actually draw it, and\nthis read never writes. Migration belongs to ExecBrandingSettings -Action Get: the settings\npage opening is a deliberate, infrequent, single-user action, which is the one place where\nrewriting the row is safe.\n\nIts role matches ListUserSettings rather than the branding write role — every user who\nrenders a report needs the branding on it, and that is exactly who could read it before.", + "description": "Returns the report branding: colours, logo and cover images, footer and watermark text.\n\nBranding was previously returned by ListUserSettings, which put every uploaded cover\ninline on every page load and ran the legacy-image migration - a write - on that read\npath. This read never writes; migration belongs to ExecBrandingSettings -Action Get.\n\nThe role matches ListUserSettings rather than the branding write role, since every user\nwho renders a report needs the branding on it.", "parameters": [ { "name": "includeGallery", @@ -44396,7 +44452,7 @@ "tags": [ "Tools > GitHub" ], - "description": "Returns release metadata for the provided repository and semantic version. Hotfix\nversions (e.g. v8.5.2) map back to the base release tag (v8.5.0).", + "description": "Returns release metadata for the provided repository. Results are cached and refreshed\nwhen the cache has no entry for the running version - hotfix releases (e.g. v8.5.2)\npublish their own notes, so a v8.5.0 entry no longer counts as current.", "responses": { "200": { "description": "Success", diff --git a/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll b/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll index adfbc858902ac..7fa6ef290c144 100644 Binary files a/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll and b/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll differ diff --git a/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll b/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll index 46721f0d1986f..6ff38bce3e839 100644 Binary files a/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll and b/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll differ diff --git a/Modules/CIPPCore/Public/Authentication/Get-CIPPSiteHostname.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CIPPSiteHostname.ps1 index 127949629e256..fd7a7249c78e5 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CIPPSiteHostname.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Get-CIPPSiteHostname.ps1 @@ -22,19 +22,26 @@ function Get-CIPPSiteHostname { .PARAMETER AsRedirectUri Return the EasyAuth callback URI for each hostname instead of the bare hostname. .PARAMETER IncludeStatus - Return an object with Hostnames, RedirectUris, Discovered and Error instead of a bare - list, so the caller can distinguish an authoritative list from a best-effort fallback. + Return an object with Hostnames, RedirectUris, DefaultHostname, CustomHostnames, + PreferredHostname, Discovered and Error instead of a bare list, so the caller can + distinguish an authoritative list from a best-effort fallback. + .PARAMETER NoFallback + Skip the best-effort fallback entirely and return nothing when ARM cannot be queried. + For callers that only act on an authoritative answer - the fallback reads the stored + instance URL, which is the very value a reconciler is trying to verify. .FUNCTIONALITY Internal #> [CmdletBinding()] param( [switch]$AsRedirectUri, - [switch]$IncludeStatus + [switch]$IncludeStatus, + [switch]$NoFallback ) $Discovered = $false $DiscoveryError = $null + $DefaultHostname = $null $Hostnames = [System.Collections.Generic.List[string]]::new() $IsLocalDev = $env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true' @@ -58,6 +65,7 @@ function Get-CIPPSiteHostname { } $SiteResponse.properties.hostNames | ForEach-Object { $Hostnames.Add($_) } + $DefaultHostname = $SiteResponse.properties.defaultHostName $Discovered = $true Write-Information "[SiteHostname] Discovered hostnames from ARM: $($Hostnames -join ', ')" } catch { @@ -65,7 +73,9 @@ function Get-CIPPSiteHostname { Write-Information "[SiteHostname] ARM hostname discovery failed — falling back to known hostnames: $DiscoveryError" } - if (-not $Discovered) { + if (-not $Discovered -and $NoFallback) { + Write-Information '[SiteHostname] -NoFallback set - returning nothing rather than a best-effort list' + } elseif (-not $Discovered) { # Best effort: the platform hostname, plus the last URL the instance was actually # served from (Config/InstanceProperties/CIPPURL), which is usually the custom domain. if ($env:WEBSITE_HOSTNAME) { $Hostnames.Add($env:WEBSITE_HOSTNAME) } @@ -99,12 +109,26 @@ function Get-CIPPSiteHostname { $RedirectUris = @($Hostnames | ForEach-Object { "https://$_/.auth/login/aad/callback" }) + # ARM names the hostname it handed out itself; outside ARM the platform variable is the same + # thing. Everything else bound to the site is a custom domain the customer put there. + if (-not $DefaultHostname) { $DefaultHostname = $env:WEBSITE_HOSTNAME } + $CustomHostnames = @($Hostnames | Where-Object { $_ -ne $DefaultHostname -and $_ -notlike '*.azurewebsites.net' }) + + # Anything that builds a user-facing URL (webhook registrations, emailed links) should use the + # custom domain, not the platform one. A site can carry several, and there is no "primary" flag + # in ARM to pick from - so take the first binding. That order is stable across restarts, which + # matters because warmup runs on every node and they all have to land on the same answer. + $PreferredHostname = if ($CustomHostnames.Count -gt 0) { $CustomHostnames[0] } else { $DefaultHostname } + if ($IncludeStatus) { return [PSCustomObject]@{ - Hostnames = @($Hostnames) - RedirectUris = $RedirectUris - Discovered = $Discovered - Error = $DiscoveryError + Hostnames = @($Hostnames) + RedirectUris = $RedirectUris + DefaultHostname = $DefaultHostname + CustomHostnames = $CustomHostnames + PreferredHostname = $PreferredHostname + Discovered = $Discovered + Error = $DiscoveryError } } if ($AsRedirectUri) { diff --git a/Modules/CIPPCore/Public/Baselines/Add-CIPPBaselineHistoryEvent.ps1 b/Modules/CIPPCore/Public/Baselines/Add-CIPPBaselineHistoryEvent.ps1 new file mode 100644 index 0000000000000..87578f029ae64 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Add-CIPPBaselineHistoryEvent.ps1 @@ -0,0 +1,50 @@ +function Add-CIPPBaselineHistoryEvent { + <# + .SYNOPSIS + Appends one operator/system event to the BaselineHistory audit trail. + .DESCRIPTION + Engine runs write their history through Set-CIPPBaselineResult; everything else + that changes baseline state writes through here so the timeline is a complete + audit trail: triage verdicts (accept/deny/clear, whole-row and per-path), + overrides, manual and automatic stage advances, task completions, and the + deletions carried out for denied deviations. Same schema and keying as run + rows (PK _, inverted-ticks RowKey → newest-first), so the + history reader needs no second source; Mode distinguishes the event class + ('triage' | 'stage' | 'delete') from engine runs ('run' | 'compare' | 'oneoff'). + Detail is the one-line human story ("Accepted: change ticket #123"); Diff is + optional structured data rendered like a run diff. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $TenantFilter, + $Standard, + $Mode, + $TriggeredBy, + $Outcome, + $Detail, + $Diff, + $RunId, + [bool]$Remediated = $false + ) + + if (-not $RunId) { $RunId = [string](New-Guid).Guid } + $HistoryTable = Get-CippTable -tablename 'BaselineHistory' + $HistoryTable.Force = $true + $InvertedTicks = '{0:D19}' -f ([DateTime]::MaxValue.Ticks - [DateTime]::UtcNow.Ticks) + Add-CIPPAzDataTableEntity @HistoryTable -Entity @{ + # '#' is forbidden in Azure Table keys - instance keys sanitize to '~', exactly + # like run rows (readers reverse the mapping). + PartitionKey = ('{0}_{1}' -f $TenantFilter, ("$Standard" -replace '#', '~')) + RowKey = ('{0}-{1}' -f $InvertedTicks, $RunId) + RunId = "$RunId" + Mode = "$Mode" + TriggeredBy = "$TriggeredBy" + Outcome = "$Outcome" + Remediated = $Remediated + Diff = $(if ($Diff) { ConvertTo-Json -Compress -Depth 100 -InputObject @($Diff) } else { '' }) + Detail = "$Detail" + } + $RunId +} diff --git a/Modules/CIPPCore/Public/Baselines/Expand-CIPPBaselineTemplatePackage.ps1 b/Modules/CIPPCore/Public/Baselines/Expand-CIPPBaselineTemplatePackage.ps1 new file mode 100644 index 0000000000000..6a5af93371b50 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Expand-CIPPBaselineTemplatePackage.ps1 @@ -0,0 +1,82 @@ +function Expand-CIPPBaselineTemplatePackage { + <# + .SYNOPSIS + Expands a template-package standard instance into one member config per tagged template. + .DESCRIPTION + Packages are V2's tag system carried forward unchanged: the single free-text + `Package` column on the templates table, grouped by name (the name IS the key - + there is no package registry). A package standard stores only the package NAME; + membership is resolved fresh on every call (late binding - the whole point: + adding a template to the package adds it to every baseline referencing the + package, without touching the baseline). + + One package instance in -> N member configs out, each shaped exactly like a + hand-added standardsConfig entry for the member standard (IntuneTemplate / + ConditionalAccessTemplate), so everything downstream - engine, prepare, identity + based conflict detection, detect-drift managed sets, labels - treats them + identically to manually configured instances. Deploy options and the action + posture are copied verbatim onto every member (V2 semantics: uniform settings + across a package; per-member overrides are what hand-added instances are for). + + Member instance keys are DERIVED and STABLE: '#p-<8 chars + of the template ref>'. Stability matters because resolved rows, history + partitions, triage verdicts and tenant overrides all key on the instance key - + membership changes must never migrate another member's state. A template removed + from the package stops resolving and the scheduled reconciliation clears its + rows; an empty or deleted package expands to zero members (never an error). + + Driven by the definition's declarative `package` block: + { "memberStandard": "IntuneTemplate", "memberVariable": "intuneTemplate", + "variable": "intuneTemplatePackage", "templatePartition": "IntuneTemplate" } + No name-based fallbacks: a definition without a package block is not expandable. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Definition, + $Config, + # Optional pre-fetched rows of the template partition, so callers iterating many + # tenants query the templates table once instead of once per tenant. + $TemplateRows + ) + + if (-not $Definition.package) { return @() } + $Variables = $Config.variables + if ($Variables -is [System.Collections.IDictionary]) { $Variables = [PSCustomObject]$Variables } + $PackageRaw = $Variables.$($Definition.package.variable) + if ($PackageRaw -is [System.Management.Automation.PSCustomObject] -and $null -ne $PackageRaw.value) { $PackageRaw = $PackageRaw.value } + $PackageName = "$PackageRaw" + if (-not $PackageName) { return @() } + + if ($null -eq $TemplateRows) { + $TemplatesTable = Get-CippTable -tablename 'templates' + $SafePartition = ConvertTo-CIPPODataFilterValue -Value "$($Definition.package.templatePartition)" + $TemplateRows = @(Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq '$SafePartition'") + } + + $InstanceSuffix = if ("$($Config.instance)" -match '#(.+)$') { $Matches[1] } else { 'pkg' } + # Sorted for deterministic expansion order; -eq on strings is case-insensitive, + # matching how the tag pickers group the column. + $Members = foreach ($TemplateRow in ($TemplateRows | Where-Object { "$($_.Package)" -eq $PackageName } | Sort-Object -Property RowKey)) { + $TemplateRef = if ("$($TemplateRow.GUID)") { "$($TemplateRow.GUID)" } else { "$($TemplateRow.RowKey)" } + $KeyPart = ($TemplateRef -replace '[^a-zA-Z0-9]', '') + if ($KeyPart.Length -gt 8) { $KeyPart = $KeyPart.Substring(0, 8) } + $MemberVariables = [PSCustomObject]@{} + foreach ($Property in $Variables.PSObject.Properties) { + if ($Property.Name -eq "$($Definition.package.variable)") { continue } + $MemberVariables | Add-Member -NotePropertyName $Property.Name -NotePropertyValue $Property.Value + } + $MemberVariables | Add-Member -NotePropertyName "$($Definition.package.memberVariable)" -NotePropertyValue $TemplateRef -Force + [PSCustomObject]@{ + standard = "$($Definition.package.memberStandard)" + instance = ('{0}#p{1}-{2}' -f $Definition.package.memberStandard, $InstanceSuffix, $KeyPart) + variables = $MemberVariables + remediateEnabled = [bool]$Config.remediateEnabled + alertEnabled = [bool]$Config.alertEnabled + alertOnRemediate = [bool]$Config.alertOnRemediate + fromPackage = $PackageName + } + } + @($Members) +} diff --git a/Modules/CIPPCore/Public/Baselines/Export-CIPPBaselineTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Export-CIPPBaselineTemplate.ps1 new file mode 100644 index 0000000000000..6ae58623e217e --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Export-CIPPBaselineTemplate.ps1 @@ -0,0 +1,134 @@ +function Export-CIPPBaselineTemplate { + <# + .SYNOPSIS + Assembles a baseline's portable export: the baseline file + every referenced template. + .DESCRIPTION + Produces the artifact set the community-repo upload pushes: + - ONE baseline file (TemplateType 'BaselineTemplate'): an AddBaseline-compatible + payload with tenant assignments replaced by the 'Exported Template' placeholder, + instance-specific alert destinations blanked, and a referencedTemplates manifest + (partition + guid + displayName + the repo path each template is pushed to). + - One templates-table ENTITY per referenced CA/Intune template, serialized exactly + like the existing UploadTemplate action (whole entity minus ETag/Timestamp) so + they round-trip through the untouched Import-CommunityTemplate path. + Package standards are expanded AT EXPORT TIME into their concrete member + instances (a snapshot of the tag's membership at this moment) - repo files carry + no package registry, so a portable baseline must reference templates directly. + The Package column still rides along on each template entity, so tags + reconstitute on the importing instance as a bonus. + Related templates are separate files, not embedded - a baseline referencing + dozens of policies would otherwise produce one enormous blob. + .OUTPUTS + $null when the baseline does not exist, else + @{ Baseline = ; Templates = @() } + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($GUID) + + $Baseline = Get-CIPPBaseline -ID $GUID | Select-Object -First 1 + if (-not $Baseline) { return $null } + + $Definitions = @(Get-CIPPBaselineDefinition) + $DefinitionsByName = @{} + foreach ($Definition in $Definitions) { if ($Definition.name) { $DefinitionsByName[$Definition.name] = $Definition } } + $TemplatesTable = Get-CippTable -tablename 'templates' + $PartitionRows = @{} + $GetPartitionRows = { + param($Partition) + if (-not $PartitionRows.ContainsKey($Partition)) { + $SafePartition = ConvertTo-CIPPODataFilterValue -Value $Partition + $PartitionRows[$Partition] = @(Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq '$SafePartition'") + } + $PartitionRows[$Partition] + } + $Unwrap = { param($Value) if ($Value -is [System.Management.Automation.PSCustomObject] -and $null -ne $Value.value) { $Value.value } else { $Value } } + + # Referenced templates dedupe on (partition, RowKey); manifest + entity collected once. + $SeenTemplates = @{} + $TemplateEntities = [System.Collections.Generic.List[object]]::new() + $Manifest = [System.Collections.Generic.List[object]]::new() + $CollectTemplate = { + param($Partition, $TemplateRef) + $Row = & $GetPartitionRows $Partition | Where-Object { $_.RowKey -like "$TemplateRef*" -or $_.GUID -eq $TemplateRef } | Select-Object -First 1 + if (-not $Row) { return $false } + $SeenKey = '{0}|{1}' -f $Partition, $Row.RowKey + if ($SeenTemplates.ContainsKey($SeenKey)) { return $true } + $SeenTemplates[$SeenKey] = $true + $TemplateJson = $(try { $Row.JSON | ConvertFrom-Json -Depth 100 } catch { $null }) + $DisplayName = "$($TemplateJson.Displayname ?? $TemplateJson.displayName ?? $TemplateJson.name ?? $Row.RowKey)" + $Basename = $DisplayName -replace '\s', '_' -replace '[^\w\d_]', '' + $TemplateEntities.Add(($Row | Select-Object -ExcludeProperty ETag, Timestamp)) + $Manifest.Add([PSCustomObject]@{ + partition = "$Partition" + guid = "$(if ("$($Row.GUID)") { $Row.GUID } else { $Row.RowKey })" + displayName = $DisplayName + path = ('{0}/{1}.json' -f $Partition, $Basename) + }) + $true + } + + $ExportStages = foreach ($Stage in $Baseline.stages) { + $ExportStandards = [System.Collections.Generic.List[object]]::new() + foreach ($Config in @($Stage.standardsConfig)) { + if (-not $Config) { continue } + $BaseName = ("$($Config.instance ?? $Config.standard)" -split '#')[0] + $Definition = $DefinitionsByName[$BaseName] + if ($Definition.package) { + # Snapshot the tag's membership NOW: the exported baseline carries the + # concrete member instances, exactly what the resolver would produce. + $Partition = "$($Definition.package.templatePartition)" + foreach ($Member in @(Expand-CIPPBaselineTemplatePackage -Definition $Definition -Config $Config -TemplateRows (& $GetPartitionRows $Partition))) { + if (-not (& $CollectTemplate $Partition "$($Member.variables.$($Definition.package.memberVariable))")) { continue } + $ExportStandards.Add([PSCustomObject]@{ + standard = $Member.standard + instance = $Member.instance + variables = $Member.variables + remediateEnabled = [bool]$Member.remediateEnabled + alertEnabled = [bool]$Member.alertEnabled + alertOnRemediate = [bool]$Member.alertOnRemediate + }) + } + continue + } + if ($Definition.instanceIdentity -and "$($Definition.remediate.executor)" -in @('IntuneTemplate', 'CATemplate')) { + $TemplateRef = "$(& $Unwrap $Config.variables.$($Definition.instanceIdentity))" + if (-not $TemplateRef -or -not (& $CollectTemplate "$($Definition.remediate.executor)" $TemplateRef)) { + Write-Information "Export-CIPPBaselineTemplate: $($Config.instance) references template '$TemplateRef' which no longer exists - skipped from the export." + continue + } + } + $ExportStandards.Add([PSCustomObject]@{ + standard = "$($Config.standard)" + instance = "$($Config.instance)" + variables = ($Config.variables ?? [PSCustomObject]@{}) + remediateEnabled = [bool]$Config.remediateEnabled + alertEnabled = [bool]$Config.alertEnabled + alertOnRemediate = [bool]$Config.alertOnRemediate + }) + } + [PSCustomObject]@{ + name = $Stage.name + logic = $Stage.logic ?? 'and' + conditions = @($Stage.conditions) + standards = @($ExportStandards) + } + } + + $BaselineFile = [PSCustomObject]@{ + TemplateType = 'BaselineTemplate' + templateName = "$($Baseline.templateName)" + description = "$($Baseline.description)" + # Tenant assignments never travel: the placeholder is visibly wrong on import, + # which is the point - the operator must consciously assign real tenants. + assignedTenants = @([PSCustomObject]@{ label = 'Exported Template'; value = 'Exported Template'; type = 'Tenant' }) + excludedTenants = @() + alertEmails = '' + alertWebhookUrl = '' + stages = @($ExportStages) + referencedTemplates = @($Manifest) + } + + @{ Baseline = $BaselineFile; Templates = @($TemplateEntities) } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 index 39f2b4ba2a6c5..cbd8ba3dcb3f1 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 @@ -85,6 +85,8 @@ function Get-CIPPBaselineAlignment { outcome = $Row.Outcome remediated = [bool]$Row.Remediated runId = $Row.RunId + detail = "$($Row.Detail)" + alerted = [bool]$Row.Alerted diff = $(if ($Row.Diff) { try { $Row.Diff | ConvertFrom-Json } catch { $null } } else { $null }) } } @@ -143,6 +145,8 @@ function Get-CIPPBaselineAlignment { triggeredBy = $_.TriggeredBy outcome = $_.Outcome remediated = [bool]$_.Remediated + detail = "$($_.Detail)" + alerted = [bool]$_.Alerted diff = if ($_.Diff) { try { $_.Diff | ConvertFrom-Json } catch { $null } } else { $null } } } @@ -196,12 +200,32 @@ function Get-CIPPBaselineAlignment { # Every standard rolled out to this tenant (current + previous stages) that the # engine has not resolved yet appears as a 'No Data' row. foreach ($StageDef in ($Baseline.stages | Select-Object -First $State.currentStage)) { - foreach ($InstanceKey in @($StageDef.standards)) { - if ($KnownStandards -contains $InstanceKey) { continue } - $KnownStandards.Add($InstanceKey) - $BaseName = ($InstanceKey -split '#')[0] - $Definition = $Definitions | Where-Object { $_.name -eq $BaseName } | Select-Object -First 1 - $Config = $StageDef.standardsConfig | Where-Object { ($_.instance ?? $_.standard) -eq $InstanceKey } | Select-Object -First 1 + foreach ($StageInstanceKey in @($StageDef.standards)) { + if ($KnownStandards -contains $StageInstanceKey) { continue } + $KnownStandards.Add($StageInstanceKey) + $StageBaseName = ($StageInstanceKey -split '#')[0] + $StageDefinition = $Definitions | Where-Object { $_.name -eq $StageBaseName } | Select-Object -First 1 + $StageConfig = $StageDef.standardsConfig | Where-Object { ($_.instance ?? $_.standard) -eq $StageInstanceKey } | Select-Object -First 1 + # Package standards never resolve under their own key: synthesize one + # row per MEMBER, with the same derived instance keys the work-item + # resolver produces - pre-run 'No Data' rows then meld into the + # engine's resolved rows instead of leaving a phantom package row. + $SynthTargets = if ($StageDefinition.package) { + $MemberDefinition = $Definitions | Where-Object { $_.name -eq "$($StageDefinition.package.memberStandard)" } | Select-Object -First 1 + @(Expand-CIPPBaselineTemplatePackage -Definition $StageDefinition -Config $StageConfig | ForEach-Object { + [PSCustomObject]@{ InstanceKey = $_.instance; Definition = $MemberDefinition; Config = $_ } + }) + } else { + @([PSCustomObject]@{ InstanceKey = $StageInstanceKey; Definition = $StageDefinition; Config = $StageConfig }) + } + foreach ($SynthTarget in $SynthTargets) { + $InstanceKey = $SynthTarget.InstanceKey + $Definition = $SynthTarget.Definition + $Config = $SynthTarget.Config + if ($StageDefinition.package) { + if ($KnownStandards -contains $InstanceKey) { continue } + $KnownStandards.Add($InstanceKey) + } $Expected = & $RenderExpected $Definition $Config.variables # Multi-instance labels carry the instance identity before the first # run too: the task name for manual tasks; for identity-carrying @@ -230,7 +254,7 @@ function Get-CIPPBaselineAlignment { pendingVerification = $false licenseAvailable = $true sourceScope = 'baseline' - sourceTemplate = $Baseline.templateName + sourceTemplate = $(if ($Config.fromPackage) { '{0} ({1})' -f $Baseline.templateName, $Config.fromPackage } else { $Baseline.templateName }) stage = $StageDef.name inheritance = @([PSCustomObject]@{ templateName = $Baseline.templateName @@ -249,6 +273,7 @@ function Get-CIPPBaselineAlignment { lastRemediated = $null history = @() }) + } } } diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 index 3625915ae82df..f01b224fa41e6 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 @@ -36,6 +36,11 @@ function Get-CIPPBaselineWorkItems { $Baselines = @(Get-CIPPBaseline) if ($TemplateId) { $Baselines = @($Baselines | Where-Object { $_.GUID -eq $TemplateId }) } $Definitions = @(Get-CIPPBaselineDefinition) + $DefinitionsByName = @{} + foreach ($Definition in $Definitions) { if ($Definition.name) { $DefinitionsByName[$Definition.name] = $Definition } } + # Template rows per partition, fetched once per call: package expansion runs inside + # the per-(baseline, tenant) loop and must not query the table every iteration. + $PackageTemplateRows = @{} # Canonical settings fingerprint: the variables only (property-order independent). # Conflict means the baselines disagree about the DESIRED STATE - the action posture @@ -91,6 +96,27 @@ function Get-CIPPBaselineWorkItems { $StageNumber++ foreach ($Config in @($Stage.standardsConfig)) { if (-not $Config) { continue } + # Package standards never become work items themselves: they expand + # here (late binding, resolved fresh every call) into one member + # config per tagged template, indistinguishable from hand-added + # instances - so identity-based dedupe/conflict, the engine, and the + # detect-drift managed sets all treat members natively. An empty or + # deleted package expands to zero members. + $ConfigDefinition = $DefinitionsByName[("$($Config.instance ?? $Config.standard)" -split '#')[0]] + if ($ConfigDefinition.package) { + $Partition = "$($ConfigDefinition.package.templatePartition)" + if (-not $PackageTemplateRows.ContainsKey($Partition)) { + $TemplatesTable = Get-CippTable -tablename 'templates' + $SafePartition = ConvertTo-CIPPODataFilterValue -Value $Partition + $PackageTemplateRows[$Partition] = @(Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq '$SafePartition'") + } + foreach ($Member in @(Expand-CIPPBaselineTemplatePackage -Definition $ConfigDefinition -Config $Config -TemplateRows $PackageTemplateRows[$Partition])) { + $StageConfigs[$Member.instance] = $Member + $StageNumbers[$Member.instance] = $StageNumber + $StageNames[$Member.instance] = $Stage.name + } + continue + } $StageConfigs[$Config.instance] = $Config $StageNumbers[$Config.instance] = $StageNumber $StageNames[$Config.instance] = $Stage.name @@ -117,7 +143,9 @@ function Get-CIPPBaselineWorkItems { AlertEnabled = [bool]$Config.alertEnabled AlertOnRemediate = [bool]$Config.alertOnRemediate SourceScope = $Scope - SourceTemplate = $Baseline.templateName + # Package members attribute their origin so the alignment view + # reads 'Baseline X (PackageName)' instead of hiding the bundle. + SourceTemplate = $(if ($Config.fromPackage) { '{0} ({1})' -f $Baseline.templateName, $Config.fromPackage } else { $Baseline.templateName }) Stage = $StageNumbers[$InstanceKey] StageName = $StageNames[$InstanceKey] AlertEmails = $Baseline.alertEmails diff --git a/Modules/CIPPCore/Public/Baselines/Import-CIPPBaselineTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Import-CIPPBaselineTemplate.ps1 new file mode 100644 index 0000000000000..523c61dcb9ad9 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Import-CIPPBaselineTemplate.ps1 @@ -0,0 +1,95 @@ +function Import-CIPPBaselineTemplate { + <# + .SYNOPSIS + Imports a BaselineTemplate file from a community repo, related templates first. + .DESCRIPTION + The baseline counterpart of the CA policy's named-locations pattern: the + baseline file carries a referencedTemplates manifest, and this function fetches + each referenced CA/Intune template file from the SAME repository and runs it + through the untouched Import-CommunityTemplate path (which preserves the file's + own RowKey/GUID - critical, because the baseline's standard instances reference + templates by exactly those ids). Only then is the baseline itself created via + New-CIPPBaseline, keeping the exported placeholder assignment ('Exported + Template') so the operator must consciously assign real tenants. + Re-imports dedupe by (Source, templateName) on the rollout row with the same + SHA-guard semantics as template imports: same blob sha -> skip unless -Force; + a match updates the existing baseline in place (same GUID) so triage and + resolved rows survive an upstream update. + Missing referenced files fall back to a tree lookup by sanitized display name + under the partition folder; still-missing ones are reported, never fatal. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Baseline, + $FullName, + $Branch, + $SHA, + $User, + [switch]$Force + ) + + if (-not $Baseline.templateName) { throw 'The baseline file carries no templateName.' } + + # Re-import dedupe: same repo + same name = the same baseline, updated in place. + $RolloutTable = Get-CippTable -tablename 'BaselineRollouts' + $SafeSource = ConvertTo-CIPPODataFilterValue -Value "$FullName" + $Existing = Get-CIPPAzDataTableEntity @RolloutTable -Filter "PartitionKey eq 'rollout' and Source eq '$SafeSource'" | + Where-Object { $_.templateName -eq "$($Baseline.templateName)" } | Select-Object -First 1 + if ($Existing -and "$($Existing.SHA)" -eq "$SHA" -and -not $Force) { + return "Baseline '$($Baseline.templateName)' is already up to date. Skipping import." + } + + # Related templates first, so the baseline's references resolve the moment it lands. + $Tree = $null + $ImportedCount = 0 + $Failures = [System.Collections.Generic.List[string]]::new() + foreach ($Reference in @($Baseline.referencedTemplates)) { + if (-not $Reference) { continue } + try { + $File = $null + if ("$($Reference.path)") { + try { $File = Get-GitHubFileContents -FullName $FullName -Path $Reference.path -Branch $Branch } catch { $File = $null } + } + if (-not $File) { + # The file moved or was renamed: find it by sanitized display name under + # its partition folder - the same convention every upload writes with. + if (-not $Tree) { $Tree = @((Get-GitHubFileTree -FullName $FullName -Branch $Branch).tree) } + $Sanitized = "$($Reference.displayName)" -replace '\s', '_' -replace '[^\w\d_]', '' + $Candidate = $Tree | Where-Object { + $_.path -match '\.json$' -and $_.path -like "$($Reference.partition)/*" -and + (((($_.path -split '/')[-1]) -replace '\.json$', '') -eq $Sanitized) + } | Select-Object -First 1 + if ($Candidate) { $File = Get-GitHubFileContents -FullName $FullName -Path $Candidate.path -Branch $Branch } + } + if (-not $File) { + $Failures.Add("$($Reference.displayName) ($($Reference.partition)) was not found in the repository") + continue + } + $Content = $File.content | ConvertFrom-Json -Depth 100 + $null = Import-CommunityTemplate -Template $Content -SHA $File.sha -Source $FullName -Force:$Force + $ImportedCount++ + } catch { + $Failures.Add("$($Reference.displayName): $($_.Exception.Message)") + } + } + + $Payload = [PSCustomObject]@{ + GUID = $(if ($Existing) { $Existing.RowKey } else { $null }) + templateName = "$($Baseline.templateName)" + description = "$($Baseline.description)" + assignedTenants = @($Baseline.assignedTenants) + excludedTenants = @() + alertEmails = '' + alertWebhookUrl = '' + stages = @($Baseline.stages) + } + $Saved = New-CIPPBaseline -Baseline $Payload -User ("$User" ? "$User" : 'GitHub Import') -Source "$FullName" -SHA "$SHA" + + $Message = "Imported baseline '$($Baseline.templateName)' ($($Saved.DeltaCount) delta rows) with $ImportedCount related template$(if ($ImportedCount -eq 1) { '' } else { 's' }). Assign it to real tenants in the editor - it arrives assigned to the 'Exported Template' placeholder." + if ($Failures.Count -gt 0) { + $Message = "$Message Missing: $($Failures -join '; ')." + } + $Message +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraduation.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraduation.ps1 index ec58c2c1f9c73..d5f8414bb2e50 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraduation.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraduation.ps1 @@ -22,6 +22,7 @@ function Invoke-CIPPBaselineGraduation { $Now = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) $StateTable = Get-CippTable -tablename 'BaselineRolloutState' $ResolvedTable = Get-CippTable -tablename 'BaselineAlignment' + $Definitions = @(Get-CIPPBaselineDefinition) foreach ($Baseline in @(Get-CIPPBaseline)) { foreach ($State in $Baseline.tenantStates) { @@ -51,8 +52,23 @@ function Invoke-CIPPBaselineGraduation { } } 'success' { - $RolledOut = @($Baseline.stages | Select-Object -First $State.currentStage | - ForEach-Object { @($_.standards) } | Select-Object -Unique) + # Package standards never resolve under their own key - expand + # them so success means EVERY MEMBER template aligned, using the + # same derived instance keys the resolver writes rows under. + $RolledOut = [System.Collections.Generic.List[string]]::new() + foreach ($StageDef in @($Baseline.stages | Select-Object -First $State.currentStage)) { + foreach ($Config in @($StageDef.standardsConfig)) { + if (-not $Config) { continue } + $BaseName = ("$($Config.instance ?? $Config.standard)" -split '#')[0] + $Definition = $Definitions | Where-Object { $_.name -eq $BaseName } | Select-Object -First 1 + if ($Definition.package) { + foreach ($Member in @(Expand-CIPPBaselineTemplatePackage -Definition $Definition -Config $Config)) { $RolledOut.Add("$($Member.instance)") } + } else { + $RolledOut.Add("$($Config.instance)") + } + } + } + $RolledOut = @($RolledOut | Select-Object -Unique) $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $State.tenantFilter $Rows = @(Get-CIPPAzDataTableEntity @ResolvedTable -Filter "PartitionKey eq '$SafeTenant'") $Aligned = 0 @@ -77,6 +93,7 @@ function Invoke-CIPPBaselineGraduation { enteredStageAt = $Now firstDeployedAt = $State.firstDeployedAt ?? $State.enteredStageAt ?? $Now } + $null = Add-CIPPBaselineHistoryEvent -TenantFilter $State.tenantFilter -Standard $Baseline.templateName -Mode 'stage' -TriggeredBy 'schedule' -Outcome 'Stage Advanced' -Detail "Graduated to stage $($State.currentStage + 1) ($($NextStage.name)) - the stage's conditions were met" Write-LogMessage -API 'Baselines' -tenant $State.tenantFilter -message "Graduated $($State.tenantFilter) to stage $($State.currentStage + 1) ($($NextStage.name)) of baseline $($Baseline.templateName)." -Sev 'Info' } } diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 index e9a0f0b307c27..e78626cb444e6 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 @@ -67,6 +67,10 @@ function Invoke-CIPPBaselineStandard { try { $Definition = Get-CIPPBaselineDefinition -Name $Item.BaseName if (-not $Definition) { throw "No definition found for standard $($Item.BaseName)." } + # Package standards are authoring artifacts: the work-item resolver expands them + # into member instances before anything is queued. One reaching the engine is a + # resolver bug - fail loudly rather than comparing a package against nothing. + if ($Definition.package) { throw "Package standard $($Item.BaseName) must be expanded by the resolver and never executes directly." } $Label = $Definition.label ?? $Item.Standard # License gate (moved out of the starter so Run Baseline Now responds instantly - @@ -485,6 +489,9 @@ function Invoke-CIPPBaselineStandard { $DeletedKeys.Add($DenyKey) continue } + # The verdict author is the accountable party - captured before the + # carried-out verdict is cleared, and stamped on the audit event. + $VerdictBy = "$($AcceptedPaths.$DenyKey.by)" try { switch ($Definition.delete.executor) { 'IntunePolicy' { Invoke-CIPPBaselineDeleteIntunePolicy -Target $Target -TenantFilter $TenantFilter } @@ -493,8 +500,12 @@ function Invoke-CIPPBaselineStandard { } $DeletedKeys.Add($DenyKey) Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Deleted `"$DenyKey`" for `"$Label`" as ordered by the denied deviation - Run $RunId" -Sev 'Info' + # A deletion is irreversible: it gets its own immutable history event + # naming the object AND who ordered it, not just Remediated=true. + $null = Add-CIPPBaselineHistoryEvent -TenantFilter $TenantFilter -Standard $Item.Standard -Mode 'delete' -TriggeredBy ($VerdictBy ? $VerdictBy : $TriggeredBy) -Outcome 'Deleted' -Detail "Deleted '$DenyKey' (id $($Target.id)) as ordered by the denied deviation" -RunId $RunId -Remediated $true } catch { Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to delete `"$DenyKey`" for `"$Label`": $($_.Exception.Message) - Run $RunId" -Sev 'Error' + $null = Add-CIPPBaselineHistoryEvent -TenantFilter $TenantFilter -Standard $Item.Standard -Mode 'delete' -TriggeredBy ($VerdictBy ? $VerdictBy : $TriggeredBy) -Outcome 'Delete Failed' -Detail "Failed to delete '$DenyKey': $($_.Exception.Message)" -RunId $RunId } } if ($DeletedKeys.Count -gt 0) { diff --git a/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1 b/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1 new file mode 100644 index 0000000000000..79116cc8e09d8 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1 @@ -0,0 +1,156 @@ +function New-CIPPBaseline { + <# + .SYNOPSIS + Creates or updates a baseline from an editor-shaped payload. + .DESCRIPTION + The core behind Invoke-AddBaseline, extracted so the community-repo import can + create baselines without synthesizing an HTTP request. There is no baseline + blob: the Baselines delta rows (design doc §4.1) are the editable source of + truth for every standard's configuration, and the BaselineRollouts row (§12.2) + holds the baseline-level data - name, description, exclusions, alert + destinations, and the ordered stage definitions. Baselines are reconstructed + from those rows on read. + Source/SHA mark a community-repo import (repo FullName + git blob sha) so the + catalog can show Imported/UpdateAvailable and re-imports dedupe; when omitted, + an existing row's markers carry forward so an editor re-save of an imported + baseline keeps its provenance. + .OUTPUTS + @{ GUID = ; DeltaCount = } + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Baseline, + $User, + $Source, + $SHA + ) + + if (-not $Baseline.templateName) { + throw 'A baseline requires a name.' + } + if (-not $Baseline.stages -or @($Baseline.stages).Count -lt 1) { + throw 'A baseline requires at least one stage.' + } + + $GUID = $Baseline.GUID ? $Baseline.GUID : (New-Guid).GUID + $Now = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) + + # Baseline-level record: metadata + ordered stage definitions (standards live on the deltas). + $StageDefinitions = @($Baseline.stages | ForEach-Object { + [PSCustomObject]@{ + name = $_.name + logic = $_.logic ?? 'and' + conditions = @($_.conditions) + } + }) + $RolloutTable = Get-CippTable -tablename 'BaselineRollouts' + $SafeGuid = ConvertTo-CIPPODataFilterValue -Value $GUID + $ExistingRollout = Get-CIPPAzDataTableEntity @RolloutTable -Filter "PartitionKey eq 'rollout' and RowKey eq '$SafeGuid'" | Select-Object -First 1 + $RolloutTable.Force = $true + # The editor round-trips the tenant selector's own option objects ({label, value, + # type}) verbatim via assignedTo/excludedTo; the flat excludedTenants values keep + # the exclusion logic simple. Raw string values are accepted everywhere too. + $ExcludedValues = @($Baseline.excludedTenants | ForEach-Object { + if ($null -eq $_) { } elseif ($_ -is [string]) { $_ } else { "$($_.value)" } + } | Where-Object { $_ }) + Add-CIPPAzDataTableEntity @RolloutTable -Entity @{ + PartitionKey = 'rollout' + RowKey = "$GUID" + templateName = "$($Baseline.templateName)" + description = "$($Baseline.description)" + assignedTo = (ConvertTo-Json -Compress -Depth 10 -InputObject @($Baseline.assignedTenants)) + excludedTo = (ConvertTo-Json -Compress -Depth 10 -InputObject @($Baseline.excludedTenants)) + excludedTenants = (ConvertTo-Json -Compress -Depth 10 -InputObject $ExcludedValues) + alertEmails = "$($Baseline.alertEmails)" + alertWebhookUrl = "$($Baseline.alertWebhookUrl)" + Stages = (ConvertTo-Json -Compress -Depth 100 -InputObject $StageDefinitions) + updatedBy = "$User" + updatedAt = $Now + Source = "$($Source ?? $ExistingRollout.Source)" + SHA = "$($SHA ?? $ExistingRollout.SHA)" + } + + # Explode into delta rows: RK --s-. + # The stage is part of the key so the same standard can exist in two stages (the + # report-only -> enforce pattern). IDs in keys, never names: group scopes key on the + # group ID; scopeName carries the display name. '#' (multi-instance marker) is not + # legal in Azure Table keys; standardName column keeps the real key. + $DeltaTable = Get-CippTable -tablename 'Baselines' + $OldDeltas = Get-CIPPAzDataTableEntity @DeltaTable -Filter "PartitionKey eq 'standardItem' and templateId eq '$SafeGuid'" + if ($OldDeltas) { + Remove-CIPPAzDataTableEntity -Force @DeltaTable -Entity $OldDeltas + } + + $Groups = @() + try { $Groups = @(Get-TenantGroups) } catch { Write-Information "New-CIPPBaseline: tenant group lookup failed: $($_.Exception.Message)" } + $Scopes = foreach ($Assignment in @($Baseline.assignedTenants)) { + if ($null -eq $Assignment) { continue } + # Selector option objects carry the key in .value; raw strings are the key itself. + $Value = if ($Assignment -is [string]) { $Assignment } else { "$($Assignment.value)" } + if (-not $Value) { continue } + if ($Value -eq 'AllTenants') { + @{ scope = 'allTenants'; scopeId = 'AllTenants'; scopeName = 'AllTenants'; segment = 'allTenants' } + } else { + # Group selections send the group ID; older saves round-tripped names. Accept both. + $Group = $Groups | Where-Object { $_.Id -eq $Value -or $_.Name -eq $Value } | Select-Object -First 1 + if ($Group) { + @{ scope = 'group'; scopeId = "$($Group.Id)"; scopeName = "$($Group.Name)"; segment = "group_$($Group.Id)" } + } else { + @{ scope = 'tenant'; scopeId = $Value; scopeName = $Value; segment = "tenant_$Value" } + } + } + } + + $RolloutId = if (@($Baseline.stages).Count -gt 1) { $GUID } else { '' } + $DeltaTable.Force = $true + $StageNumber = 0 + $DeltaCount = 0 + foreach ($Stage in $Baseline.stages) { + $StageNumber++ + foreach ($Config in @($Stage.standards)) { + if (-not $Config) { continue } + $InstanceKey = if ($Config -is [string]) { $Config } else { $Config.instance ?? $Config.standard } + $Variables = if ($Config -is [string]) { [PSCustomObject]@{} } else { $Config.variables ?? [PSCustomObject]@{} } + $SafeInstance = $InstanceKey -replace '#', '~' + foreach ($Scope in $Scopes) { + Add-CIPPAzDataTableEntity @DeltaTable -Entity @{ + PartitionKey = 'standardItem' + RowKey = ('{0}-{1}-s{2}-{3}' -f $Scope.segment, $SafeInstance, $StageNumber, $GUID) + standardName = "$InstanceKey" + templateId = "$GUID" + scope = "$($Scope.scope)" + scopeId = "$($Scope.scopeId)" + scopeName = "$($Scope.scopeName)" + stage = $StageNumber + expectedValue = (ConvertTo-Json -Compress -Depth 100 -InputObject $Variables) + remediateEnabled = [bool]$(if ($Config -is [string]) { $true } else { $Config.remediateEnabled ?? $true }) + alertEnabled = [bool]$(if ($Config -is [string]) { $true } else { $Config.alertEnabled ?? $true }) + alertOnRemediate = [bool]$(if ($Config -is [string]) { $false } else { $Config.alertOnRemediate ?? $false }) + rolloutId = "$RolloutId" + updatedBy = "$User" + updatedAt = $Now + } + $DeltaCount++ + } + } + } + + # Resolved rows for standards no longer in this baseline would linger forever - + # clear them now so the alignment view reflects the edit immediately. + $Instances = [System.Collections.Generic.List[string]]::new() + foreach ($Stage in $Baseline.stages) { + foreach ($Config in @($Stage.standards)) { + if (-not $Config) { continue } + $Instances.Add($(if ($Config -is [string]) { $Config } else { $Config.instance ?? $Config.standard })) + } + } + $ResolvedTable = Get-CippTable -tablename 'BaselineAlignment' + $Orphans = @(Get-CIPPAzDataTableEntity @ResolvedTable -Filter "TemplateId eq '$SafeGuid'" | Where-Object { $Instances -notcontains $_.StandardName }) + if ($Orphans) { + Remove-CIPPAzDataTableEntity -Force @ResolvedTable -Entity $Orphans + } + + @{ GUID = $GUID; DeltaCount = $DeltaCount } +} diff --git a/Modules/CIPPCore/Public/Baselines/Set-CIPPBaselineResult.ps1 b/Modules/CIPPCore/Public/Baselines/Set-CIPPBaselineResult.ps1 index 88bdc62962bcb..2173357163cec 100644 --- a/Modules/CIPPCore/Public/Baselines/Set-CIPPBaselineResult.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Set-CIPPBaselineResult.ps1 @@ -83,5 +83,8 @@ function Set-CIPPBaselineResult { Outcome = "$($Result.Outcome)" Remediated = [bool]$Result.Remediated Diff = $(if ($Result.Diff) { ConvertTo-Json -Compress -Depth 100 -InputObject @($Result.Diff) } else { '' }) + Detail = '' + # Whether this run raises an alert - the timeline shows delivery, not just drift. + Alerted = [bool]$Result.AlertEvent } } diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index a883008cdfdb4..809df07d75081 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -335,6 +335,32 @@ function Compare-CIPPIntuneObject { $DifferenceObject } + # CompareType 'ca': a CA template stores grantControls.authenticationStrength as a bare + # { id } reference - which is also all New-CIPPCAPolicy ever writes - while Graph returns + # the strength fully expanded on read. Comparing those two shapes reported displayName, + # description, policyType, requirementsSatisfied and allowedCombinations as drift on every + # run, which remediation could never clear because none of them are writable through the CA + # policy. Collapse both sides to the display name, the identity deployment matches on. + if ($CompareType -contains 'ca' -and $obj1 -and $obj2) { + # Clone first - the callers keep using their objects after the compare. + $obj1 = $obj1 | ConvertTo-Json -Depth 100 -Compress | ConvertFrom-Json -Depth 100 + $obj2 = $obj2 | ConvertTo-Json -Depth 100 -Compress | ConvertFrom-Json -Depth 100 + # Graph always expands on read, so the live side names the template's bare id for us. + $StrengthNames = @{} + foreach ($Side in @($obj1, $obj2)) { + $Strength = $Side.grantControls.authenticationStrength + if ($Strength -isnot [System.Management.Automation.PSCustomObject]) { continue } + if ($Strength.id -and $Strength.displayName) { $StrengthNames["$($Strength.id)"] = "$($Strength.displayName)" } + } + foreach ($Side in @($obj1, $obj2)) { + $Strength = $Side.grantControls.authenticationStrength + if ($Strength -isnot [System.Management.Automation.PSCustomObject]) { continue } + $Side.grantControls.authenticationStrength = [PSCustomObject]@{ + displayName = if ($Strength.displayName) { "$($Strength.displayName)" } else { $StrengthNames["$($Strength.id)"] ?? "$($Strength.id)" } + } + } + } + if ($obj1 -and $obj2) { Compare-ObjectsRecursively -Object1 $obj1 -Object2 $obj2 } @@ -343,11 +369,31 @@ function Compare-CIPPIntuneObject { return $null } } else { - $intuneCollection = Get-Content "$env:CIPPRootPath\Config\intuneCollection.json" | ConvertFrom-Json -ErrorAction SilentlyContinue - # Build a hashtable index for O(1) lookups instead of O(n) Where-Object scans - $intuneCollectionIndex = @{} - foreach ($item in $intuneCollection) { - if ($item.id) { $intuneCollectionIndex[$item.id] = $item } + # Process-wide, built once. Reparsing the 18MB collection on every call is what used to put + # this function over the container's GC heap hard limit - see Get-CIPPIntuneDefinitionIndex. + # A miss returns $null the way the hashtable this replaced did, but a $null key throws, so + # every lookup below stays guarded. + $intuneCollectionIndex = Get-CIPPIntuneDefinitionIndex + if ($null -eq $intuneCollectionIndex) { + $intuneCollectionIndex = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + } + + # Options are indexed by id, so resolving a raw value to its friendly label is a lookup + # rather than a Where-Object scan over the definition's option list. Values that match no + # option - and settings that carry none - come back unchanged, which is what every call + # site did for itself before. + function Resolve-SettingOptionLabel { + param ( + $Definition, + $Value + ) + + if ($null -eq $Value) { return $Value } + if (-not $Definition -or -not $Definition.options) { return $Value } + + $Label = $null + if ($Definition.options.TryGetValue("$Value", [ref]$Label) -and $Label) { return $Label } + return $Value } # Settings Intune generates per tenant. The Defender onboarding blob embeds the tenant's own @@ -401,14 +447,7 @@ function Compare-CIPPIntuneObject { '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance' { $childValue = $null if ($child.choiceSettingValue?.value) { - $option = $childIntuneObj.options | Where-Object { - $_.id -eq $child.choiceSettingValue.value - } - $childValue = if ($option?.displayName) { - $option.displayName - } else { - $child.choiceSettingValue.value - } + $childValue = Resolve-SettingOptionLabel -Definition $childIntuneObj -Value $child.choiceSettingValue.value } $results.Add([PSCustomObject]@{ @@ -435,15 +474,7 @@ function Compare-CIPPIntuneObject { if ($child.choiceSettingCollectionValue) { $values = [System.Collections.Generic.List[string]]::new() foreach ($choiceValue in $child.choiceSettingCollectionValue) { - $option = $childIntuneObj.options | Where-Object { - $_.id -eq $choiceValue.value - } - $displayValue = if ($option?.displayName) { - $option.displayName - } else { - $choiceValue.value - } - $values.Add($displayValue) + $values.Add((Resolve-SettingOptionLabel -Definition $childIntuneObj -Value $choiceValue.value)) } $childValue = ($values | Sort-Object) -join ', ' @@ -542,14 +573,7 @@ function Compare-CIPPIntuneObject { $settingInstance.settingDefinitionId } - $option = $intuneObj.options | Where-Object { - $_.id -eq $settingInstance.choiceSettingValue.value - } - $value = if ($option?.displayName) { - $option.displayName - } else { - $settingInstance.choiceSettingValue.value - } + $value = Resolve-SettingOptionLabel -Definition $intuneObj -Value $settingInstance.choiceSettingValue.value [PSCustomObject]@{ Key = "Choice-$($settingInstance.settingDefinitionId)" @@ -571,9 +595,7 @@ function Compare-CIPPIntuneObject { } $values = [System.Collections.Generic.List[string]]::new() foreach ($choiceValue in $settingInstance.choiceSettingCollectionValue) { - $option = $intuneObj.options | Where-Object { $_.id -eq $choiceValue.value } - $displayValue = if ($option?.displayName) { $option.displayName } else { $choiceValue.value } - $values.Add($displayValue) + $values.Add((Resolve-SettingOptionLabel -Definition $intuneObj -Value $choiceValue.value)) } [PSCustomObject]@{ Key = "Choice-$($settingInstance.settingDefinitionId)" @@ -651,14 +673,7 @@ function Compare-CIPPIntuneObject { $settingInstance.settingDefinitionId } - $option = $intuneObj.options | Where-Object { - $_.id -eq $settingInstance.choiceSettingValue.value - } - $value = if ($option?.displayName) { - $option.displayName - } else { - $settingInstance.choiceSettingValue.value - } + $value = Resolve-SettingOptionLabel -Definition $intuneObj -Value $settingInstance.choiceSettingValue.value [PSCustomObject]@{ Key = "Choice-$($settingInstance.settingDefinitionId)" @@ -680,9 +695,7 @@ function Compare-CIPPIntuneObject { } $values = [System.Collections.Generic.List[string]]::new() foreach ($choiceValue in $settingInstance.choiceSettingCollectionValue) { - $option = $intuneObj.options | Where-Object { $_.id -eq $choiceValue.value } - $displayValue = if ($option?.displayName) { $option.displayName } else { $choiceValue.value } - $values.Add($displayValue) + $values.Add((Resolve-SettingOptionLabel -Definition $intuneObj -Value $choiceValue.value)) } [PSCustomObject]@{ Key = "Choice-$($settingInstance.settingDefinitionId)" @@ -742,20 +755,14 @@ function Compare-CIPPIntuneObject { $refValue = $refRawValue $diffValue = $diffRawValue - if ($null -ne $settingDefinition -and $null -ne $settingDefinition.options) { - if ($null -ne $refRawValue -and $refRawValue -is [string]) { - $option = $settingDefinition.options | Where-Object { $_.id -eq $refRawValue } - if ($null -ne $option -and $null -ne $option.displayName) { - $refValue = $option.displayName - } - } + # Only strings are option ids, so anything else stays as captured. -is [string] is false + # for $null, which covers the setting being absent from one side. + if ($refRawValue -is [string]) { + $refValue = Resolve-SettingOptionLabel -Definition $settingDefinition -Value $refRawValue + } - if ($null -ne $diffRawValue -and $diffRawValue -is [string]) { - $option = $settingDefinition.options | Where-Object { $_.id -eq $diffRawValue } - if ($null -ne $option -and $null -ne $option.displayName) { - $diffValue = $option.displayName - } - } + if ($diffRawValue -is [string]) { + $diffValue = Resolve-SettingOptionLabel -Definition $settingDefinition -Value $diffRawValue } $label = if ($null -ne $settingDefinition -and $null -ne $settingDefinition.displayName) { diff --git a/Modules/CIPPCore/Public/DeltaQueries/New-GraphDeltaQuery.ps1 b/Modules/CIPPCore/Public/DeltaQueries/New-GraphDeltaQuery.ps1 index 3ff6452739af7..d5000ac434466 100644 --- a/Modules/CIPPCore/Public/DeltaQueries/New-GraphDeltaQuery.ps1 +++ b/Modules/CIPPCore/Public/DeltaQueries/New-GraphDeltaQuery.ps1 @@ -66,8 +66,22 @@ function New-GraphDeltaQuery { SkipLog = $true } Write-Information "Starting delta query orchestration for $($Tenants.Count) tenants." - Write-Information "Orchestration Input: $($InputObject | ConvertTo-Json -Compress -Depth 5)" - $Orchestration = Start-NewOrchestration -FunctionName CIPPOrchestrator -InputObject ($InputObject | ConvertTo-Json -Compress -Depth 5) + if ($env:CIPPNG -eq 'true') { + # Craft runtime: go through Start-CIPPOrchestrator like every other fan-out, so this gets + # the streamed batch handoff instead of one whole-batch string. + # + # This was the only orchestration start in the codebase that reached past that wrapper + # straight to Start-NewOrchestration, which is a Durable Functions cmdlet - it does not + # exist in the Craft runtime, so on CIPPNG this branch could never have worked. + # + # The batch-as-JSON log below is deliberately not repeated here: it serialises the entire + # batch purely to print it, which for AllTenants is the exact allocation this change exists + # to remove. The tenant count above is the part worth logging. + $Orchestration = Start-CIPPOrchestrator -InputObject ([PSCustomObject]$InputObject) + } else { + Write-Information "Orchestration Input: $($InputObject | ConvertTo-Json -Compress -Depth 5)" + $Orchestration = Start-NewOrchestration -FunctionName CIPPOrchestrator -InputObject ($InputObject | ConvertTo-Json -Compress -Depth 5) + } } else { $Table = Get-CIPPTable -TableName 'DeltaQueries' diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index e7fb500555236..4e07855b361da 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -58,8 +58,6 @@ function Start-CIPPOrchestrator { $OrchestratorName = "$OrchestratorName-$BatchQueueId" } - $BatchJson = ConvertTo-Json -InputObject @($InputObject.Batch) -Depth 10 -Compress - $PostExecFunctionName = $null $PostExecParametersJson = $null if ($InputObject.PostExecution) { @@ -69,10 +67,43 @@ function Start-CIPPOrchestrator { } } - Write-Information "Craft: Queuing orchestrator '$OrchestratorName' ($($InputObject.Batch.Count) tasks$(if ($PostExecFunctionName) { ", PostExec: $PostExecFunctionName" }))" - [Craft.Services.OrchestratorBridge]::QueueOrchestration( + # Write the batch as JSON Lines — one task per line — and hand Craft the path. + # + # This used to be a single `ConvertTo-Json @($InputObject.Batch)`, which put the entire + # fan-out in one string. That is what made per-task payloads so expensive: Set-CIPPDBCacheMailboxes + # notes it directly, because every permission batch carrying a copy of all mailboxes turned a + # 10k-mailbox tenant into 200 batches x 10k entries in ONE string. Narrowing what each batch + # carries reduced that, but the whole-batch-as-one-string shape was the reason it mattered. + # Serialising one task at a time means peak memory is one task, whether the run has 10 or 10,000 + # — and Craft parses it back a line at a time for the same reason. + # + # Depth is per task now rather than per array, so tasks get one more level than before. That can + # only include detail that was previously truncated to a type name, never less. + $BatchPath = Join-Path ([System.IO.Path]::GetTempPath()) "cipp-batch-$([guid]::NewGuid().ToString('N')).jsonl" + $TaskCount = 0 + try { + $Writer = [System.IO.StreamWriter]::new($BatchPath, $false, [System.Text.Encoding]::UTF8) + try { + foreach ($BatchItem in @($InputObject.Batch)) { + if ($null -eq $BatchItem) { continue } + $Writer.WriteLine((ConvertTo-Json -InputObject $BatchItem -Depth 10 -Compress)) + $TaskCount++ + } + } finally { + $Writer.Dispose() + } + } catch { + # Queue nothing on a partial write — a half-written batch would start a run missing tasks, + # which looks like success. Drop the file and let the caller see the failure. + Remove-Item -LiteralPath $BatchPath -Force -ErrorAction SilentlyContinue + Write-Error "Failed to write batch file for '$OrchestratorName': $($_.Exception.Message)" + throw + } + + Write-Information "Craft: Queuing orchestrator '$OrchestratorName' ($TaskCount tasks$(if ($PostExecFunctionName) { ", PostExec: $PostExecFunctionName" }))" + [Craft.Services.OrchestratorBridge]::QueueOrchestrationFromFile( $OrchestratorName, - $BatchJson, + $BatchPath, 4, $PostExecFunctionName, $PostExecParametersJson, diff --git a/Modules/CIPPCore/Public/Functions/Get-CIPPHostname.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPHostname.ps1 index 9a6b7d84f27d3..02abf305acb1b 100644 --- a/Modules/CIPPCore/Public/Functions/Get-CIPPHostname.ps1 +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPHostname.ps1 @@ -11,6 +11,11 @@ function Get-CIPPHostname { The request headers, usually $Request.Headers. Optional - omit for non-HTTP contexts. .PARAMETER Save Persist the resolved hostname to Config/InstanceProperties/CIPPURL so background jobs pick up the current URL. + .PARAMETER PreferCustomDomain + Resolve from the custom domain bound to the App Service ahead of the inbound request. + Use it for anything that outlives the request - webhook registrations, stored URLs - so + an admin who happens to browse in on the *.azurewebsites.net hostname does not pin + background work to it. .FUNCTIONALITY Internal .EXAMPLE @@ -19,12 +24,32 @@ function Get-CIPPHostname { [CmdletBinding()] param( $Headers, - [switch]$Save + [switch]$Save, + [switch]$PreferCustomDomain ) $Hostname = $null - if ($Headers) { + # Only an authoritative ARM answer wins here. When the lookup fails we fall through to the + # request host rather than guessing: demoting a working custom domain to the platform hostname + # on a transient 403 would silently rewrite every link CIPP sends out. + if ($PreferCustomDomain.IsPresent) { + try { + $SiteState = Get-CIPPSiteHostname -IncludeStatus -NoFallback + if ($SiteState.Discovered -and ![string]::IsNullOrWhiteSpace($SiteState.PreferredHostname)) { + $Hostname = $SiteState.PreferredHostname + if ($SiteState.CustomHostnames.Count -gt 1) { + Write-Information "Get-CIPPHostname: $($SiteState.CustomHostnames.Count) custom domains bound ($($SiteState.CustomHostnames -join ', ')) - using the first, '$Hostname'" + } + } else { + Write-Information "Get-CIPPHostname: custom domain lookup was not authoritative, falling back to the request host: $($SiteState.Error)" + } + } catch { + Write-Information "Get-CIPPHostname: custom domain lookup failed: $($_.Exception.Message)" + } + } + + if (!$Hostname -and $Headers) { # x-ms-original-url carries the full URL the client requested, including any custom domain $Candidates = @( $Headers.'x-ms-original-url' diff --git a/Modules/CIPPCore/Public/Functions/Update-CIPPInstanceHostname.ps1 b/Modules/CIPPCore/Public/Functions/Update-CIPPInstanceHostname.ps1 new file mode 100644 index 0000000000000..a31c933cf0d05 --- /dev/null +++ b/Modules/CIPPCore/Public/Functions/Update-CIPPInstanceHostname.ps1 @@ -0,0 +1,89 @@ +function Update-CIPPInstanceHostname { + <# + .SYNOPSIS + Reconciles the stored instance URL with the custom domain bound to this App Service. + .DESCRIPTION + Config/InstanceProperties/CIPPURL is what background work builds links from - scheduled + notifications, drift emails, audit log downloads - and what the Partner Center webhook + subscription is registered against. Nothing writes it on its own: it is set from whichever + request happened to call Get-CIPPHostname -Save, so binding a new custom domain (or + removing the old one) leaves it pointing at the previous URL until an admin notices and + re-saves the automated onboarding page by hand. + + Running this at warmup makes the instance self-healing: ARM is asked which hostnames are + actually bound, the first custom domain wins (falling back to the platform hostname when + there is none), and the stored value is corrected if it drifted. + + A failed ARM lookup is NOT drift. Only an authoritative answer can tell a removed custom + domain from an unreachable management endpoint, so anything less leaves the stored value + alone - writing on a transient 403 would demote a working custom domain to the + *.azurewebsites.net hostname and quietly break every link CIPP emails out. + + Never throws; warmup steps are soft-fail by design. + .FUNCTIONALITY + Internal + .EXAMPLE + Update-CIPPInstanceHostname + #> + [CmdletBinding()] + param() + + $State = [PSCustomObject]@{ + StoredHostname = $null + ResolvedHostname = $null + CustomHostnames = @() + Updated = $false + Reason = $null + } + + try { + $SiteState = Get-CIPPSiteHostname -IncludeStatus -NoFallback + $State.CustomHostnames = @($SiteState.CustomHostnames) + + if (-not $SiteState.Discovered) { + $State.Reason = "Bound hostnames could not be enumerated - leaving the stored instance URL alone: $($SiteState.Error)" + Write-Information "[Instance-URL] $($State.Reason)" + return $State + } + + $Resolved = $SiteState.PreferredHostname + if ([string]::IsNullOrWhiteSpace($Resolved)) { + $State.Reason = 'ARM returned no usable hostname for this site - nothing to reconcile' + Write-Information "[Instance-URL] $($State.Reason)" + return $State + } + $State.ResolvedHostname = $Resolved + + if ($State.CustomHostnames.Count -gt 1) { + Write-Information "[Instance-URL] $($State.CustomHostnames.Count) custom domains bound ($($State.CustomHostnames -join ', ')) - using the first, '$Resolved'" + } elseif ($State.CustomHostnames.Count -eq 0) { + Write-Information "[Instance-URL] No custom domain bound - using the platform hostname '$Resolved'" + } + + $ConfigTable = Get-CIPPTable -TableName 'Config' + $Stored = Get-CIPPAzDataTableEntity @ConfigTable -Filter "PartitionKey eq 'InstanceProperties' and RowKey eq 'CIPPURL'" + $State.StoredHostname = $Stored.Value + + if ($Stored.Value -eq $Resolved) { + $State.Reason = "Stored instance URL already matches the bound domain ($Resolved)" + Write-Information "[Instance-URL] $($State.Reason)" + return $State + } + + $Entity = @{ + PartitionKey = 'InstanceProperties' + RowKey = 'CIPPURL' + Value = [string]$Resolved + } + Add-CIPPAzDataTableEntity @ConfigTable -Entity $Entity -Force + + $State.Updated = $true + $State.Reason = "Updated stored instance URL from '$($Stored.Value)' to '$Resolved'" + Write-Information "[Instance-URL] $($State.Reason)" + } catch { + $State.Reason = "Instance URL reconciliation failed (non-fatal): $($_.Exception.Message)" + Write-Information "[Instance-URL] $($State.Reason)" + } + + return $State +} diff --git a/Modules/CIPPCore/Public/Get-CIPPImage.ps1 b/Modules/CIPPCore/Public/Get-CIPPImage.ps1 index e30f01cea7370..f55fe7dcda4af 100644 --- a/Modules/CIPPCore/Public/Get-CIPPImage.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPImage.ps1 @@ -11,16 +11,8 @@ Image payloads are often larger than a single table property/entity, so Add-CIPPAzDataTableEntity may store them across the original RowKey and - `{RowKey}-partN` rows. Filters must include those part rows or - Get-CIPPAzDataTableEntity cannot reassemble `data`. - - Those rows are matched by RowKey range, not by OriginalEntityId. The Edm - type of that property is decided by whatever wrote the row, so naming it - in a filter means guessing: comparing it to both a string and a guid - literal is accepted by Azurite and rejected outright by the real table - service, and a rejected query looks identical here to a missing image - - which is how an uploaded logo could sit visibly in the table and still - read back as "no logo". A RowKey range needs no type at all. + `{RowKey}-partN` rows. Get-CIPPAzDataTableEntity reassembles them, fetching + any part rows this filter does not match. .PARAMETER PartitionKey Image kind / purpose (e.g. logo, brandingCover). Required. .PARAMETER Id @@ -46,9 +38,13 @@ } $Table = Get-CIPPTable -TableName 'Images' + + # Include OriginalEntityId so multi-row entities are fetched with their parts, matching the + # other large-entity reads in CIPP. Any part this misses is fetched by + # Get-AzDataTableLargeEntity before reassembly. $IdClauses = foreach ($ImageId in $Ids) { $SafeId = $ImageId.Replace("'", "''") - "(RowKey ge '$SafeId' and RowKey lt '$SafeId~')" + "(RowKey eq '$SafeId' or OriginalEntityId eq '$SafeId')" } $Filter = "PartitionKey eq '$($PartitionKey.Replace("'","''"))' and ($($IdClauses -join ' or '))" diff --git a/Modules/CIPPCore/Public/Get-CIPPIntuneDefinitionIndex.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneDefinitionIndex.ps1 new file mode 100644 index 0000000000000..d2d1b84dcd734 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPIntuneDefinitionIndex.ps1 @@ -0,0 +1,104 @@ +function Get-CIPPIntuneDefinitionIndex { + <# + .SYNOPSIS + Returns the process-wide index of Intune setting definitions used by Catalog comparisons. + .DESCRIPTION + Config/intuneCollection.json is an 18MB single-line array of ~18,000 setting definitions. + Compare-CIPPIntuneObject used to read and parse the whole file on every call - once per + Intune template, per tenant - retaining roughly 357MB of managed heap each time. With eight + background workers comparing concurrently that clears the container's 2,398MB + DOTNET_GCHeapHardLimit, and the comparison dies with an OutOfMemoryException that the + standard then reports to the technician as drift. + + A comparison only ever needs two things from a definition: its display name, and the + id -> display name map of its options. This projects exactly that and nothing else, which + is about a third of the size of the parsed collection, then hands the same instance to + every subsequent caller. Callers must treat the result as read-only. + + Lookups are ordinal-case-insensitive to match the -eq comparisons this replaced. Indexing a + missing key returns $null, the same as the hashtable it replaced; indexing a $null key + throws, so callers must keep guarding for that. + + The cache is keyed on the file's length and last write time, so a container image that + ships a new collection is picked up without needing the worker to be recycled. + .EXAMPLE + $Index = Get-CIPPIntuneDefinitionIndex + $Definition = $Index['device_vendor_msft_policy_config_defender_allowrealtimemonitoring'] + $Label = $Definition.displayName + $Value = $Definition.options['1'] + .NOTES + Returns $null when the collection is missing, which leaves comparisons falling back to raw + setting ids rather than failing outright. + #> + [CmdletBinding()] + param() + + # Interpolated rather than Join-Path deliberately - this is the path expression the collection + # has always been read with, and path handling differs between the dev and container hosts. + $Path = "$env:CIPPRootPath\Config\intuneCollection.json" + + # An index we already hold is always better than none. If the collection cannot be read or + # parsed right now, keep serving the cached one rather than degrading every comparison to raw + # setting ids - that reads to a technician as drift on every setting of every policy. + $File = Get-Item -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $File) { + if ($script:CIPPIntuneDefinitionIndex) { + return $script:CIPPIntuneDefinitionIndex + } + Write-Information "Intune setting definitions not found at $Path - Catalog comparisons will report raw setting ids." + return $null + } + + $Stamp = '{0}|{1}' -f $File.Length, $File.LastWriteTimeUtc.Ticks + if ($script:CIPPIntuneDefinitionIndex -and $script:CIPPIntuneDefinitionIndexStamp -eq $Stamp) { + return $script:CIPPIntuneDefinitionIndex + } + + $Index = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + + try { + # ReadAllText over Get-Content: the file is one 18MB line, so Get-Content pays to split it + # into pipeline objects that ConvertFrom-Json then reassembles. -AsHashtable skips building + # a PSCustomObject graph that is discarded as soon as the projection below is built. + $Definitions = [System.IO.File]::ReadAllText($Path) | ConvertFrom-Json -AsHashtable -ErrorAction Stop + } catch { + if ($script:CIPPIntuneDefinitionIndex) { + Write-Information "Could not parse Intune setting definitions at $Path - keeping the previously loaded definitions. Error: $($_.Exception.Message)" + return $script:CIPPIntuneDefinitionIndex + } + Write-Information "Could not parse Intune setting definitions at $Path - Catalog comparisons will report raw setting ids. Error: $($_.Exception.Message)" + return $null + } + + foreach ($Definition in $Definitions) { + $Id = "$($Definition.id)" + if (-not $Id) { continue } + + # Definitions without options are the common case; leaving this $null keeps the projection + # small and lets callers test it before attempting a lookup. + $Options = $null + if ($Definition.options) { + $Options = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Option in $Definition.options) { + if ($null -eq $Option.id) { continue } + $Options["$($Option.id)"] = "$($Option.displayName)" + } + } + + # A definition with no display name stringifies to '', which is falsy - the call sites + # already treat that as "no label" and fall back to the setting id. + $Index[$Id] = @{ + displayName = "$($Definition.displayName)" + options = $Options + } + } + + # The parsed collection is several times the size of the projection. Drop the reference before + # returning so it is collectable now rather than whenever this scope happens to unwind. + $Definitions = $null + + $script:CIPPIntuneDefinitionIndex = $Index + $script:CIPPIntuneDefinitionIndexStamp = $Stamp + + return $Index +} diff --git a/Modules/CIPPCore/Public/GraphHelper/New-DeviceLogin.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-DeviceLogin.ps1 index cf8db7a27a291..747c355edb364 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-DeviceLogin.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-DeviceLogin.ps1 @@ -11,29 +11,39 @@ function New-DeviceLogin { [string]$device_code, [string]$TenantId ) + + # The device code request and the token poll have to agree on both authority and scope. + # The authority previously diverged - the poll was hard-coded to /organizations while the + # device code request honoured -TenantId - which silently breaks any tenant-scoped login. + $Authority = if ($TenantId) { $TenantId } else { 'organizations' } + + # Callers vary in whether they already include the OIDC scopes, so union them in rather + # than appending unconditionally, which sent them twice on the wire. + $ScopeList = [System.Collections.Generic.List[string]]@($scope -split '\s+' | Where-Object { $_ }) + foreach ($RequiredScope in @('offline_access', 'profile', 'openid')) { + if (-not $ScopeList.Contains($RequiredScope)) { $ScopeList.Add($RequiredScope) } + } + $RequestScope = $ScopeList -join ' ' + if ($FirstLogon) { $Body = @{ client_id = $Clientid - scope = "$scope offline_access profile openid" - } - if ($TenantID) { - $ReturnCode = Invoke-CIPPRestMethod -Uri "https://login.microsoftonline.com/$($TenantID)/oauth2/v2.0/devicecode" -Method POST -Body $Body -ContentType 'application/x-www-form-urlencoded' - } else { - $ReturnCode = Invoke-CIPPRestMethod -Uri 'https://login.microsoftonline.com/organizations/oauth2/v2.0/devicecode' -Method POST -Body $Body -ContentType 'application/x-www-form-urlencoded' + scope = $RequestScope } + $ReturnCode = Invoke-CIPPRestMethod -Uri "https://login.microsoftonline.com/$Authority/oauth2/v2.0/devicecode" -Method POST -Body $Body -ContentType 'application/x-www-form-urlencoded' } else { $Body = @{ client_id = $Clientid - scope = "$scope offline_access profile openid" + scope = $RequestScope grant_type = 'device_code' device_code = $device_code } - $Checking = Invoke-CIPPRestMethod -SkipHttpErrorCheck -Uri 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token' -Method POST -Body $Body -ContentType 'application/x-www-form-urlencoded' - if ($checking.refresh_token) { - $ReturnCode = $Checking - } else { - $returncode = $Checking.error - } + # Return the whole response, success or not. Collapsing failures to $Checking.error + # threw away error_description, which is where the AADSTS code lives - so a device + # code sign-in blocked by security defaults or a Conditional Access authentication + # flows policy surfaced as an unexplained failure. Callers distinguish the two cases + # by testing for refresh_token. + $ReturnCode = Invoke-CIPPRestMethod -SkipHttpErrorCheck -Uri "https://login.microsoftonline.com/$Authority/oauth2/v2.0/token" -Method POST -Body $Body -ContentType 'application/x-www-form-urlencoded' } return $ReturnCode } diff --git a/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 b/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 index 30e9eccbd0362..6fa625020660a 100644 --- a/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 @@ -88,6 +88,32 @@ function Test-CIPPAccessPermissions { } ) | Out-Null } + + # Entra records the flow a refresh token was originally obtained through, and + # Conditional Access re-evaluates it on every redemption - including the per-tenant + # redemptions CIPP makes for GDAP. So a token family that began as a device code + # login keeps tripping device code flow blocks (security defaults now enforces one) + # in every customer tenant that has one, surfacing as a Conditional Access error on + # ordinary Graph calls. The weekly token rotation cannot clear it: rotation reuses + # the original authentication context rather than re-authenticating. Only a fresh + # authorization code sign-in mints a clean family. + # No access token claim records this, so read it from the partner tenant's + # non-interactive sign-ins, which are the redemptions themselves. + try { + $SignInFilter = "appId eq '$($env:ApplicationID)' and signInEventTypes/any(t: t eq 'nonInteractiveUser')" + $SamSignIns = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?api-version=beta&`$filter=$SignInFilter&`$top=10&`$select=createdDateTime,originalTransferMethod,authenticationProtocol" -tenantid $env:TenantID -NoAuthCheck $true -ErrorAction Stop + $DeviceCodeSignIn = $SamSignIns | Where-Object { $_.originalTransferMethod -eq 'deviceCodeFlow' -or $_.authenticationProtocol -eq 'deviceCode' } | Select-Object -First 1 + if ($DeviceCodeSignIn) { + $ErrorMessages.Add('Your refresh token originated from a device code login. Security defaults and Conditional Access authentication flow policies block that flow when the token is redeemed, which fails Graph calls in affected tenants with a Conditional Access error. Refresh your SAM tokens to sign in again - the weekly token update will not replace it.') | Out-Null + $Success = $false + } else { + $Messages.Add('Your refresh token did not originate from a device code login.') | Out-Null + } + } catch { + # Reading sign-in logs needs AuditLog.Read.All and an Entra ID P1 licence. Not + # having either is not an access check failure, it just leaves this unknown. + $Messages.Add('Could not determine whether your refresh token originated from a device code login. Reading sign-in logs requires AuditLog.Read.All and an Entra ID P1 license.') | Out-Null + } } diff --git a/Modules/CIPPCore/Public/Webhooks/Update-CIPPPartnerWebhookUrl.ps1 b/Modules/CIPPCore/Public/Webhooks/Update-CIPPPartnerWebhookUrl.ps1 new file mode 100644 index 0000000000000..6918cfee4bf9f --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Update-CIPPPartnerWebhookUrl.ps1 @@ -0,0 +1,111 @@ +function Update-CIPPPartnerWebhookUrl { + <# + .SYNOPSIS + Re-registers the Partner Center webhook subscription when it points at a stale CIPP URL. + .DESCRIPTION + The subscription held in Partner Center records the exact address CIPP was reachable at when + it was created, and it does not follow a change of hostname. Partner Center carries on + delivering events to the old address, so tenants silently stop being onboarded and partner + alerts stop arriving with nothing failing outright - the only fix was for an admin to notice + the warning on the Automated Onboarding page and re-save it by hand. + + Runs at warmup, straight after Update-CIPPInstanceHostname has settled what this instance is + published on, so a container that comes up on a new custom domain repairs its own + subscription. + + Deliberately conservative, because unlike the rest of warmup this writes to an external + service. It does nothing unless: + - automated onboarding is enabled. A disabled subscription is not CIPP's to correct. + - ARM authoritatively confirmed the bound hostname. The stored instance URL is NOT good + enough here: on a fresh container it is the platform hostname, so repairing from it + would point a working custom domain at *.azurewebsites.net rather than fix anything. + - Partner Center is actually holding a different URL. A failed read is not a mismatch, so + an unreachable Partner Center means no write at all. + + The currently registered event types are carried across, so a repair never silently narrows + the subscription to the two events New-CIPPGraphSubscription requires. + + Warmup runs on every node, so several may repair the same stale subscription at once. The + writes are identical and the losers of that race find the URL already correct. + + Never throws; warmup steps are soft-fail by design. + .FUNCTIONALITY + Internal + .EXAMPLE + Update-CIPPPartnerWebhookUrl + #> + [CmdletBinding()] + param() + + $State = [PSCustomObject]@{ + Enabled = $false + CurrentUrl = $null + ExpectedUrl = $null + Updated = $false + Reason = $null + } + + try { + $ConfigTable = Get-CIPPTable -TableName 'Config' + $WebhookConfig = Get-CIPPAzDataTableEntity @ConfigTable -Filter "RowKey eq 'PartnerWebhookOnboarding'" + if ($WebhookConfig.Enabled -ne $true) { + $State.Reason = 'Automated onboarding is disabled - leaving the Partner Center subscription alone' + Write-Information "[Partner-Webhook] $($State.Reason)" + return $State + } + $State.Enabled = $true + + if ([string]::IsNullOrWhiteSpace($env:TenantID)) { + $State.Reason = 'No partner tenant is configured yet - nothing to reconcile' + Write-Information "[Partner-Webhook] $($State.Reason)" + return $State + } + + $SiteState = Get-CIPPSiteHostname -IncludeStatus -NoFallback + if (-not $SiteState.Discovered -or [string]::IsNullOrWhiteSpace($SiteState.PreferredHostname)) { + $State.Reason = "Bound hostnames could not be enumerated - not rewriting an external registration on a guess: $($SiteState.Error)" + Write-Information "[Partner-Webhook] $($State.Reason)" + return $State + } + + $Hostname = $SiteState.PreferredHostname + $State.ExpectedUrl = "https://$Hostname/api/PublicWebhooks?CIPPID=$($env:TenantID)&Type=PartnerCenter" + + # Read before write, in its own guard: if Partner Center cannot be reached we must not fall + # through and "repair" a subscription whose current state we never established. + try { + $Existing = New-GraphGetRequest -uri 'https://api.partnercenter.microsoft.com/webhooks/v1/registration' -tenantid $env:TenantID -NoAuthCheck $true -scope 'https://api.partnercenter.microsoft.com/.default' + } catch { + $State.Reason = "Could not read the current Partner Center subscription - skipping: $($_.Exception.Message)" + Write-Information "[Partner-Webhook] $($State.Reason)" + return $State + } + $State.CurrentUrl = $Existing.webhookUrl + + if ($Existing.webhookUrl -eq $State.ExpectedUrl) { + $State.Reason = "Partner Center already points at $($State.ExpectedUrl)" + Write-Information "[Partner-Webhook] $($State.Reason)" + return $State + } + + if ($SiteState.CustomHostnames.Count -gt 1) { + Write-Information "[Partner-Webhook] $($SiteState.CustomHostnames.Count) custom domains bound ($($SiteState.CustomHostnames -join ', ')) - registering against the first, '$Hostname'" + } + Write-Information "[Partner-Webhook] Subscription points at '$($Existing.webhookUrl)' but this instance is published on '$Hostname' - re-registering" + + # Pass the registered events back in, or the re-registration drops everything the admin + # selected down to the two events New-CIPPGraphSubscription always adds. + $EventTypes = @($Existing.webhookEvents) + $Result = New-CIPPGraphSubscription -PartnerCenter -BaseURL $Hostname -EventType $EventTypes -APIName 'Partner Webhook Reconciliation' + + # New-CIPPGraphSubscription reports its own failures as a returned string rather than throwing + $State.Reason = [string]$Result + $State.Updated = $State.Reason -notmatch '^Failed' + Write-Information "[Partner-Webhook] $($State.Reason)" + } catch { + $State.Reason = "Partner Center webhook reconciliation failed (non-fatal): $($_.Exception.Message)" + Write-Information "[Partner-Webhook] $($State.Reason)" + } + + return $State +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCredentialUserRegistrationDetails.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCredentialUserRegistrationDetails.ps1 index bdeed843fb5e2..81da27cacbe62 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCredentialUserRegistrationDetails.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCredentialUserRegistrationDetails.ps1 @@ -20,30 +20,26 @@ function Set-CIPPDBCacheCredentialUserRegistrationDetails { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching credential user registration details' -sev Debug # A row per user, iterated once, so it is streamed into the writer instead of held whole. - # The writer is opened on the first record: an empty report previously skipped - # Add-CIPPDbItem altogether, and piping into it unconditionally would run its end block - # and overwrite the count row with 0. - $Writer = $null + # The writer is opened before the pipeline on purpose: GetSteppablePipeline() captures + # whichever scope is live, so opening it inside ForEach-Object captures the Graph call's + # scope, which is gone by End() - the end block then fails with "is not recognized". + # A language 'foreach' avoids that too, but buffers the whole report first. $CachedCount = 0 + $Writer = { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CredentialUserRegistrationDetails' -AddCount }.GetSteppablePipeline() + $Writer.Begin($true) try { New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/reports/credentialUserRegistrationDetails' -tenantid $TenantFilter -Stream | ForEach-Object { - if ($null -eq $Writer) { - $Writer = { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CredentialUserRegistrationDetails' -AddCount }.GetSteppablePipeline() - $Writer.Begin($true) - } $CachedCount++ $Writer.Process($_) } - if ($Writer) { + # An empty report must not run the end block (it would zero the count row), nor must + # a part-way failure (its orphan cleanup). Dispose() alone does not run it. + if ($CachedCount -gt 0) { $Writer.End() - $Writer.Dispose() - $Writer = $null Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $CachedCount credential user registration details" -sev Debug } } finally { - # Only set if the stream threw part-way: dispose without End so a partial run never - # triggers the writer's orphan cleanup. - if ($Writer) { $Writer.Dispose() } + $Writer.Dispose() } } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 index 1bacedbf58913..413cc53c29e69 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 @@ -20,30 +20,26 @@ function Set-CIPPDBCacheManagedDeviceEncryptionStates { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching managed device encryption states' -sev Debug # A row per device, iterated once, so it is streamed into the writer instead of held whole. - # The writer is opened on the first record: an empty result previously skipped - # Add-CIPPDbItem altogether, and piping into it unconditionally would run its end block - # and overwrite the count row with 0. - $Writer = $null + # The writer is opened before the pipeline on purpose: GetSteppablePipeline() captures + # whichever scope is live, so opening it inside ForEach-Object captures the Graph call's + # scope, which is gone by End() - the end block then fails with "is not recognized". + # A language 'foreach' avoids that too, but buffers the whole result first. $CachedCount = 0 + $Writer = { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDeviceEncryptionStates' -AddCount }.GetSteppablePipeline() + $Writer.Begin($true) try { New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDeviceEncryptionStates?$top=999' -tenantid $TenantFilter -Stream | ForEach-Object { - if ($null -eq $Writer) { - $Writer = { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDeviceEncryptionStates' -AddCount }.GetSteppablePipeline() - $Writer.Begin($true) - } $CachedCount++ $Writer.Process($_) } - if ($Writer) { + # An empty result must not run the end block (it would zero the count row), nor must + # a part-way failure (its orphan cleanup). Dispose() alone does not run it. + if ($CachedCount -gt 0) { $Writer.End() - $Writer.Dispose() - $Writer = $null Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $CachedCount managed device encryption states" -sev Debug } } finally { - # Only set if the stream threw part-way: dispose without End so a partial run never - # triggers the writer's orphan cleanup. - if ($Writer) { $Writer.Dispose() } + $Writer.Dispose() } } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheUserRegistrationDetails.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheUserRegistrationDetails.ps1 index b66110ff5807d..85f935916a070 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheUserRegistrationDetails.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheUserRegistrationDetails.ps1 @@ -20,30 +20,26 @@ function Set-CIPPDBCacheUserRegistrationDetails { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching user registration details' -sev Debug # A row per user, iterated once, so it is streamed into the writer instead of held whole. - # The writer is opened on the first record: an empty report previously skipped - # Add-CIPPDbItem altogether, and piping into it unconditionally would run its end block - # and overwrite the count row with 0. - $Writer = $null + # The writer is opened before the pipeline on purpose: GetSteppablePipeline() captures + # whichever scope is live, so opening it inside ForEach-Object captures the Graph call's + # scope, which is gone by End() - the end block then fails with "is not recognized". + # A language 'foreach' avoids that too, but buffers the whole report first. $CachedCount = 0 + $Writer = { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'UserRegistrationDetails' -AddCount }.GetSteppablePipeline() + $Writer.Begin($true) try { New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/reports/authenticationMethods/userRegistrationDetails' -tenantid $TenantFilter -Stream | ForEach-Object { - if ($null -eq $Writer) { - $Writer = { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'UserRegistrationDetails' -AddCount }.GetSteppablePipeline() - $Writer.Begin($true) - } $CachedCount++ $Writer.Process($_) } - if ($Writer) { + # An empty report must not run the end block (it would zero the count row), nor must + # a part-way failure (its orphan cleanup). Dispose() alone does not run it. + if ($CachedCount -gt 0) { $Writer.End() - $Writer.Dispose() - $Writer = $null Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $CachedCount user registration details" -sev Debug } } finally { - # Only set if the stream threw part-way: dispose without End so a partial run never - # triggers the writer's orphan cleanup. - if ($Writer) { $Writer.Dispose() } + $Writer.Dispose() } } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecPartnerWebhook.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecPartnerWebhook.ps1 index 75f6298da2f6b..82a6bc823ad30 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecPartnerWebhook.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecPartnerWebhook.ps1 @@ -37,10 +37,22 @@ function Invoke-ExecPartnerWebhook { } # The URL that would be registered if the subscription were saved right now, so the UI can - # flag a subscription still pointing at a previous CIPP URL. - $CurrentHostname = Get-CIPPHostname -Headers $Request.Headers + # flag a subscription still pointing at a previous CIPP URL. Resolved from the custom + # domain bound to the instance rather than the host the admin browsed in on - otherwise + # visiting the *.azurewebsites.net URL tells you to re-register against it, which is the + # opposite of what warmup reconciles the stored URL to. + $CurrentHostname = Get-CIPPHostname -Headers $Request.Headers -PreferCustomDomain if ($CurrentHostname) { $Results | Add-Member -MemberType NoteProperty -Name 'expectedWebhookUrl' -Value "https://$CurrentHostname/api/PublicWebhooks?CIPPID=$($env:TenantID)&Type=PartnerCenter" -Force + $Results | Add-Member -MemberType NoteProperty -Name 'instanceHostname' -Value $CurrentHostname -Force + } + + # Surfaced so the UI can explain which domain was picked when several are bound. + try { + $SiteState = Get-CIPPSiteHostname -IncludeStatus -NoFallback + $Results | Add-Member -MemberType NoteProperty -Name 'customDomains' -Value @($SiteState.CustomHostnames) -Force + } catch { + Write-Information "ExecPartnerWebhook: custom domain lookup failed: $($_.Exception.Message)" } } 'CreateSubscription' { @@ -48,8 +60,10 @@ function Invoke-ExecPartnerWebhook { $Request.Body.EventType = $Request.Body.EventType.value } - # Resolve the URL CIPP is served from at the time of submit, and store it for background jobs - $BaseURL = Get-CIPPHostname -Headers $Request.Headers -Save + # Resolve the URL CIPP is published on and store it for background jobs. The bound custom + # domain wins over the request host so a save made from the *.azurewebsites.net URL does + # not register Partner Center against a hostname warmup will reconcile away again. + $BaseURL = Get-CIPPHostname -Headers $Request.Headers -PreferCustomDomain -Save $Webhook = @{ TenantFilter = $env:TenantID PartnerCenter = $true diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 index 85f315b990122..b44a44522925f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 @@ -16,7 +16,7 @@ function Invoke-ExecAddTrustedIP { }) } - $tenantDomain = (Get-CIPPDomain -TenantFilter $tenantfilter).defaultDomainName + $tenantDomain = (Get-Tenants -TenantFilter $tenantfilter).defaultDomainName if (-not $tenantDomain) { return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1 index 15a9fb1dae7d5..cfd362267670a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1 @@ -1,7 +1,7 @@ Function Invoke-ExecBrandingSettings { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE CIPP.AppSettings.ReadWrite #> diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListBrandingSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListBrandingSettings.ps1 index 87470dade6c0f..ec745d056c592 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListBrandingSettings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListBrandingSettings.ps1 @@ -7,26 +7,16 @@ .DESCRIPTION Returns the report branding: colours, logo and cover images, footer and watermark text. - This used to ride along inside ListUserSettings, which meant every page load carried every - uploaded cover as an inline data URL — megabytes of images fetched to render a settings - menu. Worse, hydrating branding also ran the legacy-image migration, so a GET issued on - every page load wrote the BrandingSettings row back from a snapshot it had read moments - earlier, and any upload that landed in between was overwritten. + Branding was previously returned by ListUserSettings, which put every uploaded cover + inline on every page load and ran the legacy-image migration - a write - on that read + path. This read never writes; migration belongs to ExecBrandingSettings -Action Get. - Branding therefore has its own endpoint, fetched by the things that actually draw it, and - this read never writes. Migration belongs to ExecBrandingSettings -Action Get: the settings - page opening is a deliberate, infrequent, single-user action, which is the one place where - rewriting the row is safe. - - Its role matches ListUserSettings rather than the branding write role — every user who - renders a report needs the branding on it, and that is exactly who could read it before. + The role matches ListUserSettings rather than the branding write role, since every user + who renders a report needs the branding on it. .PARAMETER includeGallery - Return every uploaded logo and cover, not just the selected ones. - - Only the branding settings page needs the galleries, and it is the difference between a - response of a few hundred KB and one of several MB: the uploads are inline base64 and an - MSP accumulates them. A report needs the logo and cover that are actually selected, which - `logo` and `coverImage` already carry, so it asks for neither list. + Return every uploaded logo and cover, not just the selected ones. Only the branding + settings page needs them, and inline base64 makes it the difference between a response of + a few hundred KB and several MB. A report uses `logo` and `coverImage`, returned either way. #> [CmdletBinding()] param($Request, $TriggerMetadata) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomVariables.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomVariables.ps1 index 1964316d82ee2..ee78c5c671c36 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomVariables.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomVariables.ps1 @@ -272,14 +272,18 @@ function Invoke-ListCustomVariables { } } + # Resolved through Get-CIPPTextReplacement so there is one definition of what each + # token means. System tokens are skipped: they are expanded on the endpoint, not here. $Resolvable = @($VariableMap.Values | Where-Object { $_.Type -eq 'reserved' -and $_.Category -ne 'system' }) if ($Resolvable.Count -gt 0) { + # Joined into one call by a separator no domain, GUID or URL can contain. $Separator = '<<|CIPPVAR|>>' $Tokens = @($Resolvable | ForEach-Object { $_.Variable }) $Resolved = @((Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text ($Tokens -join $Separator)) -split ([regex]::Escape($Separator))) + # A mismatch would misalign every value after it, so none are used. if ($Resolved.Count -eq $Tokens.Count) { for ($i = 0; $i -lt $Tokens.Count; $i++) { if ($Resolved[$i] -ne $Tokens[$i]) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecDeviceCodeLogon.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecDeviceCodeLogon.ps1 index 69b1659238d5d..ad3b10fbff0fe 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecDeviceCodeLogon.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecDeviceCodeLogon.ps1 @@ -16,40 +16,100 @@ function Invoke-ExecDeviceCodeLogon { if (!$scope) { $scope = 'https://graph.microsoft.com/.default' } + + # clientId and scope arrive straight off the query string and this endpoint hands back + # a usable token, so without these guards it brokers a device code login for any + # application in the operator's tenant. Restrict it to the clients the setup wizard + # actually drives: Azure PowerShell (used to create the SAM app before one exists), + # Graph Explorer (the component's fallback when no app id is known yet), and CIPP's + # own SAM app. + $AllowedClientIds = [System.Collections.Generic.List[string]]@( + '1950a258-227b-4e31-a9cf-717495945fc2' + '1b730954-1685-4b74-9bfd-dac224a7b894' + ) + if ($env:ApplicationID) { $AllowedClientIds.Add($env:ApplicationID) } + + if ($clientId -notin $AllowedClientIds) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ + error = 'unsupported_client' + error_description = "The client id '$clientId' is not permitted for device code logon." + } | ConvertTo-Json + Headers = @{'Content-Type' = 'application/json' } + }) + } + + $InvalidScopes = @($scope -split '\s+' | Where-Object { + $_ -and $_ -notin @('openid', 'profile', 'email', 'offline_access') -and $_ -notlike 'https://graph.microsoft.com/*' + }) + if ($InvalidScopes.Count -gt 0) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ + error = 'invalid_scope' + error_description = "Unsupported scope(s) for device code logon: $($InvalidScopes -join ', ')" + } | ConvertTo-Json + Headers = @{'Content-Type' = 'application/json' } + }) + } + if ($Request.Query.operation -eq 'getDeviceCode') { $deviceCodeInfo = New-DeviceLogin -clientid $clientId -scope $scope -FirstLogon -TenantId $tenantId - $Results = @{ - user_code = $deviceCodeInfo.user_code - device_code = $deviceCodeInfo.device_code - verification_uri = $deviceCodeInfo.verification_uri - expires_in = $deviceCodeInfo.expires_in - interval = $deviceCodeInfo.interval - message = $deviceCodeInfo.message + if ($deviceCodeInfo.user_code) { + $Results = @{ + user_code = $deviceCodeInfo.user_code + device_code = $deviceCodeInfo.device_code + verification_uri = $deviceCodeInfo.verification_uri + expires_in = $deviceCodeInfo.expires_in + interval = $deviceCodeInfo.interval + message = $deviceCodeInfo.message + } + } else { + $Results = @{ + error = $deviceCodeInfo.error ?? 'device_code_error' + error_description = $deviceCodeInfo.error_description ?? 'Failed to request a device code.' + } } } elseif ($Request.Query.operation -eq 'checkToken') { - $tokenInfo = New-DeviceLogin -clientid $clientId -scope $scope -device_code $deviceCode + $tokenInfo = New-DeviceLogin -clientid $clientId -scope $scope -device_code $deviceCode -TenantId $tenantId if ($tokenInfo.refresh_token) { + # The refresh token is deliberately not returned. Nothing downstream consumes it - + # the wizard only forwards the access token to ExecCreateSamApp - and returning it + # would put a long-lived credential into the browser for no reason. The token CIPP + # runs on is minted by the authorization code step instead. $Results = @{ status = 'success' access_token = $tokenInfo.access_token - refresh_token = $tokenInfo.refresh_token id_token = $tokenInfo.id_token expires_in = $tokenInfo.expires_in ext_expires_in = $tokenInfo.ext_expires_in } } else { + # Only authorization_pending and slow_down mean "keep polling". Reporting every + # failure as pending made terminal errors - an expired code, a declined consent, + # or a device code flow block from security defaults or a Conditional Access + # authentication flows policy - look like the user simply had not finished + # signing in, so the wizard span until the code expired with nothing to show. + $Status = switch ($tokenInfo.error) { + 'authorization_pending' { 'pending' } + 'slow_down' { 'slow_down' } + default { 'error' } + } $Results = @{ - status = 'pending' + status = $Status error = $tokenInfo.error error_description = $tokenInfo.error_description } } } } catch { + # ErrorDetails carries the response body from the token endpoint, which is where the + # AADSTS code lives; the exception message on its own is just the status line. $Results = @{ error = 'server_error' - error_description = "An error occurred: $($_.Exception.Message)" + error_description = "An error occurred: $($_.ErrorDetails.Message ?? $_.Exception.Message)" } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSamSecretStatus.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSamSecretStatus.ps1 new file mode 100644 index 0000000000000..06cdc3c424844 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSamSecretStatus.ps1 @@ -0,0 +1,76 @@ +function Invoke-ExecSamSecretStatus { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + CIPP.AppSettings.ReadWrite + .SYNOPSIS + Reports whether the stored SAM application secret is usable yet. + .DESCRIPTION + The setup wizard creates a client secret on one step and uses it on the next, but Entra + can take several minutes to replicate a newly created secret. Until it has, every token + request fails with AADSTS7000215 even though the value CIPP holds is correct. This lets + the wizard wait on that instead of failing the user after they have already signed in. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + try { + $null = Get-CIPPAuthentication + + # Same placeholder set the deployment template seeds and Get-CIPPAuthentication skips. + $PlaceholderPattern = '^(LongApplicationId|AppSecret|RefreshToken|tenantId)$' + $Configured = $env:ApplicationID -and $env:ApplicationID -notmatch $PlaceholderPattern -and + $env:ApplicationSecret -and $env:ApplicationSecret -notmatch $PlaceholderPattern -and + $env:TenantID -and $env:TenantID -notmatch $PlaceholderPattern + + if (-not $Configured) { + $Results = @{ + ready = $false + reason = 'notConfigured' + message = 'The application registration has not been created yet. Complete the application step first.' + } + } else { + # A client credentials request is the cheapest way to ask Entra whether the secret is + # live, and going direct deliberately bypasses CIPP's token cache - a token cached + # before the secret was replaced would report ready when it is not. The authority has + # to be tenant scoped; /common and /organizations do not accept this grant. + $Body = @{ + client_id = $env:ApplicationID + client_secret = $env:ApplicationSecret + scope = 'https://graph.microsoft.com/.default' + grant_type = 'client_credentials' + } + $Response = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$($env:TenantID)/oauth2/v2.0/token" -Method POST -Body $Body -ContentType 'application/x-www-form-urlencoded' -SkipHttpErrorCheck + + if ($Response.access_token) { + $Results = @{ ready = $true; reason = 'ready' } + } elseif ($Response.error_description -match 'AADSTS7000215') { + $Results = @{ + ready = $false + reason = 'propagating' + message = 'The application secret has been created but Microsoft has not finished activating it. This usually takes a few minutes and needs nothing recreating.' + } + } else { + # Anything else is a real problem - a deleted secret, a disabled application - + # and must not be presented as something waiting will fix. + $Results = @{ + ready = $false + reason = 'error' + message = $Response.error_description ?? 'The application secret could not be validated.' + } + } + } + } catch { + $Results = @{ + ready = $false + reason = 'error' + message = (Get-CippException -Exception $_).NormalizedError + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Results + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 index db27f6333a4b9..c1f1523d52a67 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 @@ -97,9 +97,8 @@ function Invoke-ListUserSettings { Write-Warning "Failed to convert UserBookmarks JSON: $($_.Exception.Message)" } - # Branding is deliberately not returned here. It used to be, which put every uploaded cover - # image inline as a data URL in a response fetched on every page load, and ran the legacy - # image migration — a write — on that same read path. See Invoke-ListBrandingSettings. + # Branding is served by Invoke-ListBrandingSettings, not from here: it carries inline + # images and its migration writes, neither of which belong on every page load. if ($UserSpecificSettings) { $UserSettings | Add-Member -MemberType NoteProperty -Name 'UserSpecificSettings' -Value $UserSpecificSettings -Force | Out-Null diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 index 78b5577f9ce73..5600b161541bf 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 @@ -136,8 +136,33 @@ function Invoke-ListTenants { $Body = $Tenants } if ($Request.Query.Mode -eq 'TenantList') { + # Index tenant group membership by customerId so each tenant can carry the + # groups it belongs to. Get-TenantGroups is cached and already scoped to the + # groups the calling user is allowed to see, so restricted users only get theirs. + $GroupsByCustomerId = @{} + try { + foreach ($Group in @(Get-TenantGroups)) { + foreach ($Member in @($Group.Members)) { + if (!$Member.customerId) { continue } + if (-not $GroupsByCustomerId.ContainsKey($Member.customerId)) { + $GroupsByCustomerId[$Member.customerId] = [System.Collections.Generic.List[object]]::new() + } + $GroupsByCustomerId[$Member.customerId].Add([PSCustomObject]@{ + Name = $Group.Name + GroupType = $Group.GroupType + }) + } + } + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Failed to retrieve tenant groups for the tenant list. The error is: $($_.Exception.Message)" -Sev 'Warning' + } + # add portal link properties - $Body = $Body | Select-Object *, @{Name = 'portal_m365'; Expression = { "https://admin.cloud.microsoft/?delegatedOrg=$($_.initialDomainName)" } }, + # The unary comma on tenantGroups is required: Select-Object unrolls calculated + # property values, which would turn a single group into a bare object and no + # groups into $null instead of an empty array. + $Body = $Body | Select-Object *, @{Name = 'tenantGroups'; Expression = { , @($GroupsByCustomerId[$_.customerId] | Sort-Object -Property Name) } }, + @{Name = 'portal_m365'; Expression = { "https://admin.cloud.microsoft/?delegatedOrg=$($_.initialDomainName)" } }, @{Name = 'portal_exchange'; Expression = { "https://admin.cloud.microsoft/exchange?delegatedOrg=$($_.initialDomainName)" } }, @{Name = 'portal_entra'; Expression = { "https://entra.microsoft.com/$($_.defaultDomainName)" } }, @{Name = 'portal_teams'; Expression = { "https://admin.teams.microsoft.com?delegatedOrg=$($_.initialDomainName)" } }, diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBaseline.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBaseline.ps1 index d0bceb52572be..7aa6685d0ab3c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBaseline.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBaseline.ps1 @@ -9,138 +9,19 @@ function Invoke-AddBaseline { delta rows (design doc §4.1) are the editable source of truth for every standard's configuration, and the BaselineRollouts row (§12.2) holds the baseline-level data - name, description, exclusions, alert destinations, and the ordered stage definitions. - Baselines are reconstructed from those rows on read. + Baselines are reconstructed from those rows on read. The actual write lives in + New-CIPPBaseline, shared with the community-repo import. #> [CmdletBinding()] param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint try { - if (-not $Request.Body.templateName) { - throw 'A baseline requires a name.' - } - if (-not $Request.Body.stages -or @($Request.Body.stages).Count -lt 1) { - throw 'A baseline requires at least one stage.' - } - - $GUID = $Request.Body.GUID ? $Request.Body.GUID : (New-Guid).GUID $User = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails - $Now = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) - - # Baseline-level record: metadata + ordered stage definitions (standards live on the deltas). - $StageDefinitions = @($Request.Body.stages | ForEach-Object { - [PSCustomObject]@{ - name = $_.name - logic = $_.logic ?? 'and' - conditions = @($_.conditions) - } - }) - $RolloutTable = Get-CippTable -tablename 'BaselineRollouts' - $RolloutTable.Force = $true - # The editor round-trips the tenant selector's own option objects ({label, value, - # type}) verbatim via assignedTo/excludedTo; the flat excludedTenants values keep - # the exclusion logic simple. Raw string values are accepted everywhere too. - $ExcludedValues = @($Request.Body.excludedTenants | ForEach-Object { - if ($null -eq $_) { } elseif ($_ -is [string]) { $_ } else { "$($_.value)" } - } | Where-Object { $_ }) - Add-CIPPAzDataTableEntity @RolloutTable -Entity @{ - PartitionKey = 'rollout' - RowKey = "$GUID" - templateName = "$($Request.Body.templateName)" - description = "$($Request.Body.description)" - assignedTo = (ConvertTo-Json -Compress -Depth 10 -InputObject @($Request.Body.assignedTenants)) - excludedTo = (ConvertTo-Json -Compress -Depth 10 -InputObject @($Request.Body.excludedTenants)) - excludedTenants = (ConvertTo-Json -Compress -Depth 10 -InputObject $ExcludedValues) - alertEmails = "$($Request.Body.alertEmails)" - alertWebhookUrl = "$($Request.Body.alertWebhookUrl)" - Stages = (ConvertTo-Json -Compress -Depth 100 -InputObject $StageDefinitions) - updatedBy = "$User" - updatedAt = $Now - } - - # Explode into delta rows: RK --s-. - # The stage is part of the key so the same standard can exist in two stages (the - # report-only -> enforce pattern). IDs in keys, never names: group scopes key on the - # group ID; scopeName carries the display name. '#' (multi-instance marker) is not - # legal in Azure Table keys; standardName column keeps the real key. - $DeltaTable = Get-CippTable -tablename 'Baselines' - $SafeGuid = ConvertTo-CIPPODataFilterValue -Value $GUID - $OldDeltas = Get-CIPPAzDataTableEntity @DeltaTable -Filter "PartitionKey eq 'standardItem' and templateId eq '$SafeGuid'" - if ($OldDeltas) { - Remove-CIPPAzDataTableEntity -Force @DeltaTable -Entity $OldDeltas - } - - $Groups = @() - try { $Groups = @(Get-TenantGroups) } catch { Write-Information "AddBaseline: tenant group lookup failed: $($_.Exception.Message)" } - $Scopes = foreach ($Assignment in @($Request.Body.assignedTenants)) { - if ($null -eq $Assignment) { continue } - # Selector option objects carry the key in .value; raw strings are the key itself. - $Value = if ($Assignment -is [string]) { $Assignment } else { "$($Assignment.value)" } - if (-not $Value) { continue } - if ($Value -eq 'AllTenants') { - @{ scope = 'allTenants'; scopeId = 'AllTenants'; scopeName = 'AllTenants'; segment = 'allTenants' } - } else { - # Group selections send the group ID; older saves round-tripped names. Accept both. - $Group = $Groups | Where-Object { $_.Id -eq $Value -or $_.Name -eq $Value } | Select-Object -First 1 - if ($Group) { - @{ scope = 'group'; scopeId = "$($Group.Id)"; scopeName = "$($Group.Name)"; segment = "group_$($Group.Id)" } - } else { - @{ scope = 'tenant'; scopeId = $Value; scopeName = $Value; segment = "tenant_$Value" } - } - } - } - - $RolloutId = if (@($Request.Body.stages).Count -gt 1) { $GUID } else { '' } - $DeltaTable.Force = $true - $StageNumber = 0 - $DeltaCount = 0 - foreach ($Stage in $Request.Body.stages) { - $StageNumber++ - foreach ($Config in @($Stage.standards)) { - if (-not $Config) { continue } - $InstanceKey = if ($Config -is [string]) { $Config } else { $Config.instance ?? $Config.standard } - $Variables = if ($Config -is [string]) { [PSCustomObject]@{} } else { $Config.variables ?? [PSCustomObject]@{} } - $SafeInstance = $InstanceKey -replace '#', '~' - foreach ($Scope in $Scopes) { - Add-CIPPAzDataTableEntity @DeltaTable -Entity @{ - PartitionKey = 'standardItem' - RowKey = ('{0}-{1}-s{2}-{3}' -f $Scope.segment, $SafeInstance, $StageNumber, $GUID) - standardName = "$InstanceKey" - templateId = "$GUID" - scope = "$($Scope.scope)" - scopeId = "$($Scope.scopeId)" - scopeName = "$($Scope.scopeName)" - stage = $StageNumber - expectedValue = (ConvertTo-Json -Compress -Depth 100 -InputObject $Variables) - remediateEnabled = [bool]$(if ($Config -is [string]) { $true } else { $Config.remediateEnabled ?? $true }) - alertEnabled = [bool]$(if ($Config -is [string]) { $true } else { $Config.alertEnabled ?? $true }) - alertOnRemediate = [bool]$(if ($Config -is [string]) { $false } else { $Config.alertOnRemediate ?? $false }) - rolloutId = "$RolloutId" - updatedBy = "$User" - updatedAt = $Now - } - $DeltaCount++ - } - } - } - - # Resolved rows for standards no longer in this baseline would linger forever - - # clear them now so the alignment view reflects the edit immediately. - $Instances = [System.Collections.Generic.List[string]]::new() - foreach ($Stage in $Request.Body.stages) { - foreach ($Config in @($Stage.standards)) { - if (-not $Config) { continue } - $Instances.Add($(if ($Config -is [string]) { $Config } else { $Config.instance ?? $Config.standard })) - } - } - $ResolvedTable = Get-CippTable -tablename 'BaselineAlignment' - $Orphans = @(Get-CIPPAzDataTableEntity @ResolvedTable -Filter "TemplateId eq '$SafeGuid'" | Where-Object { $Instances -notcontains $_.StandardName }) - if ($Orphans) { - Remove-CIPPAzDataTableEntity -Force @ResolvedTable -Entity $Orphans - } + $Saved = New-CIPPBaseline -Baseline $Request.Body -User $User - Write-LogMessage -headers $Request.Headers -API $APIName -message "Baseline $($Request.Body.templateName) ($GUID) saved; $DeltaCount delta rows written." -Sev 'Info' - $Results = [pscustomobject]@{ Results = 'Successfully saved the baseline'; Metadata = @{ id = $GUID; deltas = $DeltaCount } } + Write-LogMessage -headers $Request.Headers -API $APIName -message "Baseline $($Request.Body.templateName) ($($Saved.GUID)) saved; $($Saved.DeltaCount) delta rows written." -Sev 'Info' + $Results = [pscustomobject]@{ Results = 'Successfully saved the baseline'; Metadata = @{ id = $Saved.GUID; deltas = $Saved.DeltaCount } } $StatusCode = [HttpStatusCode]::OK } catch { Write-LogMessage -headers $Request.Headers -API $APIName -message "Failed to save baseline: $($_.Exception.Message)" -Sev 'Error' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineOverride.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineOverride.ps1 index 8e08c78849f03..432d90b8f0d7f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineOverride.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineOverride.ps1 @@ -99,6 +99,7 @@ function Invoke-ExecBaselineOverride { Add-CIPPAzDataTableEntity @ResolvedTable -Entity $ResolvedEntity } $Message = "Created a tenant override of $Standard for $TenantFilter." + $null = Add-CIPPBaselineHistoryEvent -TenantFilter $TenantFilter -Standard $Standard -Mode 'triage' -TriggeredBy $User -Outcome 'Override Created' -Detail 'Created a tenant override - its configured values replace what the baselines apply' } 'deleteOverride' { $SafeDeltaRowKey = ConvertTo-CIPPODataFilterValue -Value $DeltaRowKey @@ -128,6 +129,7 @@ function Invoke-ExecBaselineOverride { } } $Message = "Removed the tenant override of $Standard for $TenantFilter - it falls back to the inherited configuration." + $null = Add-CIPPBaselineHistoryEvent -TenantFilter $TenantFilter -Standard $Standard -Mode 'triage' -TriggeredBy $User -Outcome 'Override Removed' -Detail 'Removed the tenant override - the standard falls back to the inherited configuration' } default { throw "Unknown action '$Action'. Use createOverride or deleteOverride." diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineStage.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineStage.ps1 index 464c31c55bad7..633e4512b9c4b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineStage.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineStage.ps1 @@ -44,6 +44,8 @@ function Invoke-ExecBaselineStage { } $StageName = $Baseline.stages[$NewStage - 1].name + $User = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails + $null = Add-CIPPBaselineHistoryEvent -TenantFilter $TenantFilter -Standard $Baseline.templateName -Mode 'stage' -TriggeredBy $User -Outcome 'Stage Advanced' -Detail "Moved to stage $NewStage ($StageName) - the stage's standards apply on the next run" Write-LogMessage -headers $Request.Headers -API $APIName -message "Moved $TenantFilter to stage $NewStage ($StageName) of baseline $($Baseline.templateName)." -Sev 'Info' $Results = [pscustomobject]@{ Results = "Moved $TenantFilter to stage $NewStage ($StageName) of $($Baseline.templateName). The stage's standards apply on the next run." } $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 index 0c48ed6b2801e..60b798984737c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 @@ -31,6 +31,7 @@ function Invoke-ExecUpdateBaselineDeviation { $Table = Get-CippTable -tablename 'BaselineAlignment' $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter $SafeStandard = ConvertTo-CIPPODataFilterValue -Value $Standard + $User = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails # Standard-view bulk complete: mark this manual task done on EVERY tenant's row. if ($Action -eq 'CompleteTask' -and $TenantFilter -in @('AllTenants', 'allTenants')) { @@ -48,6 +49,7 @@ function Invoke-ExecUpdateBaselineDeviation { $TaskEntity | Add-Member -NotePropertyName 'CurrentValue' -NotePropertyValue (ConvertTo-Json -Compress -Depth 20 -InputObject $CurrentTask) -Force } Add-CIPPAzDataTableEntity @Table -Entity $TaskEntity + $null = Add-CIPPBaselineHistoryEvent -TenantFilter $TaskEntity.PartitionKey -Standard $Standard -Mode 'triage' -TriggeredBy $User -Outcome 'Task Completed' -Detail 'Marked completed for all tenants from the standard view' } $Message = "Marked the manual task $Standard as completed for $($Entities.Count) tenant$($Entities.Count -eq 1 ? '' : 's')." Write-LogMessage -headers $Request.Headers -API $APIName -message $Message -Sev 'Info' @@ -66,7 +68,6 @@ function Invoke-ExecUpdateBaselineDeviation { } $Entity = $Entity | Select-Object -First 1 - $User = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails $Now = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) $Set = { param($Name, $Value) $Entity | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force } @@ -79,6 +80,8 @@ function Invoke-ExecUpdateBaselineDeviation { & $Set 'DeviationExpires' ($Request.Body.expires ?? '') & $Set 'RemediateOnExpire' ([bool]$Request.Body.remediateOnExpire) $Message = "Accepted the deviation on $Standard for $TenantFilter." + $EventOutcome = 'Accepted' + $EventDetail = 'Accepted the deviation{0}{1}' -f $(if ($Request.Body.reason) { ": $($Request.Body.reason)" }), $(if ($Request.Body.expires) { " (expires $($Request.Body.expires))" }) } 'Deny' { $Method = $Request.Body.method ?? 'remediate' @@ -95,6 +98,8 @@ function Invoke-ExecUpdateBaselineDeviation { # block the (whole-object) remediation, so the deny supersedes them. & $Set 'AcceptedPaths' '{}' $Message = "Denied the deviation on $Standard for $TenantFilter - $Method pending on the next run." + $EventOutcome = $(if ($Method -eq 'delete') { 'Denied - Delete Ordered' } else { 'Denied - Remediation Ordered' }) + $EventDetail = 'Denied the deviation{0} - {1} on the next run' -f $(if ($Request.Body.reason) { ": $($Request.Body.reason)" }), $(if ($Method -eq 'delete') { 'the object is deleted' } else { 'the baseline is enforced' }) } 'Clear' { # Only a triaged status re-surfaces as Drift; clearing leftover accepted @@ -111,6 +116,8 @@ function Invoke-ExecUpdateBaselineDeviation { # behind would flip straight back to (Partially) Accepted on the next run. & $Set 'AcceptedPaths' '{}' $Message = "Cleared the triage and any accepted properties on $Standard for $TenantFilter - it re-surfaces as Drift." + $EventOutcome = 'Triage Cleared' + $EventDetail = 'Cleared the triage and any accepted properties - the deviation re-surfaces as Drift' } { $_ -in @('AcceptPath', 'DenyPath', 'ClearPath') } { $Path = $Request.Body.path @@ -122,6 +129,8 @@ function Invoke-ExecUpdateBaselineDeviation { if ($Action -eq 'ClearPath') { $AcceptedPaths.PSObject.Properties.Remove($Path) $Message = "Cleared the triage on property $Path of $Standard for $TenantFilter - it re-surfaces as drift on the next run." + $EventOutcome = 'Property Triage Cleared' + $EventDetail = "Cleared the triage on '$Path' - it re-surfaces as drift on the next run" } else { $Verdict = if ($Action -eq 'DenyPath') { 'denyDelete' } else { 'accept' } $AcceptedPaths | Add-Member -NotePropertyName $Path -NotePropertyValue ([PSCustomObject]@{ @@ -135,6 +144,8 @@ function Invoke-ExecUpdateBaselineDeviation { } else { "Accepted the deviation on property $Path of $Standard for $TenantFilter. Other properties keep alerting." } + $EventOutcome = $(if ($Action -eq 'DenyPath') { 'Property Denied' } else { 'Property Accepted' }) + $EventDetail = '{0} ''{1}''{2}' -f $(if ($Action -eq 'DenyPath') { 'Denied - queued for deletion:' } else { 'Accepted the deviation on' }), $Path, $(if ($Request.Body.reason) { " - $($Request.Body.reason)" }) } & $Set 'AcceptedPaths' (ConvertTo-Json -Compress -Depth 20 -InputObject $AcceptedPaths) # Reflect the verdict in the status immediately instead of waiting for the @@ -181,6 +192,8 @@ function Invoke-ExecUpdateBaselineDeviation { & $Set 'CurrentValue' (ConvertTo-Json -Compress -Depth 20 -InputObject $Current) } $Message = "Marked the manual task $Standard as completed for $TenantFilter." + $EventOutcome = 'Task Completed' + $EventDetail = 'Marked the manual task as completed' } default { throw "Unknown action '$Action'. Use Accept, Deny, Clear, AcceptPath, DenyPath, ClearPath, or CompleteTask." @@ -189,6 +202,7 @@ function Invoke-ExecUpdateBaselineDeviation { $Table.Force = $true Add-CIPPAzDataTableEntity @Table -Entity $Entity + $null = Add-CIPPBaselineHistoryEvent -TenantFilter $TenantFilter -Standard $Standard -Mode 'triage' -TriggeredBy $User -Outcome $EventOutcome -Detail $EventDetail Write-LogMessage -headers $Request.Headers -API $APIName -message $Message -Sev 'Info' $Results = [pscustomobject]@{ Results = $Message } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 index 7eadc85197c19..fbc2278de8023 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 @@ -166,6 +166,38 @@ function Invoke-ExecCommunityRepo { } } } + 'UploadBaseline' { + # A baseline is not a templates-table row: Export-CIPPBaselineTemplate + # assembles the portable set - the BaselineTemplate file plus one standard + # template file per referenced CA/Intune template (packages expanded to + # their current members). Related templates are separate files, exactly the + # shape UploadTemplate writes, so they import through the untouched path. + $GUID = $Request.Body.GUID + $Branch = $RepoEntity.UploadBranch ?? $RepoEntity.DefaultBranch + $Export = Export-CIPPBaselineTemplate -GUID $GUID + if ($Export) { + $Message = $Request.Body.Message + foreach ($TemplateEntity in $Export.Templates) { + $TemplateJson = $(try { $TemplateEntity.JSON | ConvertFrom-Json -Depth 100 } catch { $null }) + $DisplayName = "$($TemplateJson.Displayname ?? $TemplateJson.displayName ?? $TemplateJson.name ?? $TemplateEntity.RowKey)" + $Basename = $DisplayName -replace '\s', '_' -replace '[^\w\d_]', '' + $Path = '{0}/{1}.json' -f $TemplateEntity.PartitionKey, $Basename + $null = Push-GitHubContent -FullName $Request.Body.FullName -Path $Path -Content ($TemplateEntity | ConvertTo-Json -Compress -Depth 100) -Message $Message -Branch $Branch + } + $BaselineBasename = "$($Export.Baseline.templateName)" -replace '\s', '_' -replace '[^\w\d_]', '' + $BaselinePath = 'BaselineTemplate/{0}.json' -f $BaselineBasename + $null = Push-GitHubContent -FullName $Request.Body.FullName -Path $BaselinePath -Content ($Export.Baseline | ConvertTo-Json -Compress -Depth 100) -Message $Message -Branch $Branch + $Results = @{ + resultText = "Baseline '$($Export.Baseline.templateName)' uploaded with $(@($Export.Templates).Count) related template$(if (@($Export.Templates).Count -eq 1) { '' } else { 's' })" + state = 'success' + } + } else { + $Results = @{ + resultText = "Baseline '$($GUID)' not found" + state = 'error' + } + } + } 'SetBranch' { if (!$RepoEntity) { $Results = @{ @@ -196,25 +228,38 @@ function Invoke-ExecCommunityRepo { $Template = Get-GitHubFileContents -FullName $FullName -Path $Path -Branch $Branch $Content = $Template.content | ConvertFrom-Json - if ($Content.'@odata.type' -like '*conditionalAccessPolicy*') { - $Files = (Get-GitHubFileTree -FullName $FullName -Branch $Branch).tree | Where-Object { $_.path -match '.json$' -and $_.path -notmatch 'NativeImport' } | Select-Object *, @{n = 'html_url'; e = { "https://github.com/$($SplatParams.FullName)/tree/$($SplatParams.Branch)/$($_.path)" } }, @{n = 'name'; e = { ($_.path -split '/')[ -1 ] -replace '\.json$', '' } } - - $MigrationTable = $Files | Where-Object { $_.name -eq 'MigrationTable' } | Select-Object -Last 1 - if ($MigrationTable) { - Write-Host "Found a migration table, getting contents for $FullName" - $MigrationTable = (Get-GitHubFileContents -FullName $FullName -Branch $Branch -Path $MigrationTable.path).content | ConvertFrom-Json + if ($Content.TemplateType -eq 'BaselineTemplate') { + # Baseline files carry a referencedTemplates manifest: the importer + # fetches every related template from this repo first (the same + # related-items pattern as CA named locations), then creates the + # baseline itself. Never a templates-table write. + $User = $(try { ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails } catch { $null }) + $ImportResult = Import-CIPPBaselineTemplate -Baseline $Content -FullName $FullName -Branch $Branch -SHA $Template.sha -User $User -Force:$Force + $Results = @{ + resultText = $ImportResult ?? 'Baseline imported' + state = 'success' } + } else { + if ($Content.'@odata.type' -like '*conditionalAccessPolicy*') { + $Files = (Get-GitHubFileTree -FullName $FullName -Branch $Branch).tree | Where-Object { $_.path -match '.json$' -and $_.path -notmatch 'NativeImport' } | Select-Object *, @{n = 'html_url'; e = { "https://github.com/$($SplatParams.FullName)/tree/$($SplatParams.Branch)/$($_.path)" } }, @{n = 'name'; e = { ($_.path -split '/')[ -1 ] -replace '\.json$', '' } } + + $MigrationTable = $Files | Where-Object { $_.name -eq 'MigrationTable' } | Select-Object -Last 1 + if ($MigrationTable) { + Write-Host "Found a migration table, getting contents for $FullName" + $MigrationTable = (Get-GitHubFileContents -FullName $FullName -Branch $Branch -Path $MigrationTable.path).content | ConvertFrom-Json + } - $NamedLocations = $Files | Where-Object { $_.name -match 'ALLOWED COUNTRIES' } - $LocationData = foreach ($Location in $NamedLocations) { - (Get-GitHubFileContents -FullName $FullName -Branch $Branch -Path $Location.path).content | ConvertFrom-Json + $NamedLocations = $Files | Where-Object { $_.name -match 'ALLOWED COUNTRIES' } + $LocationData = foreach ($Location in $NamedLocations) { + (Get-GitHubFileContents -FullName $FullName -Branch $Branch -Path $Location.path).content | ConvertFrom-Json + } } - } - $ImportResult = Import-CommunityTemplate -Template $Content -SHA $Template.sha -MigrationTable $MigrationTable -LocationData $LocationData -Source $FullName -Force:$Force + $ImportResult = Import-CommunityTemplate -Template $Content -SHA $Template.sha -MigrationTable $MigrationTable -LocationData $LocationData -Source $FullName -Force:$Force - $Results = @{ - resultText = $ImportResult ?? 'Template imported' - state = 'success' + $Results = @{ + resultText = $ImportResult ?? 'Template imported' + state = 'success' + } } } catch { $Results = @{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListCommunityRepoTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListCommunityRepoTemplates.ps1 index 4e073e82ac183..b4e30f5613999 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListCommunityRepoTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListCommunityRepoTemplates.ps1 @@ -58,21 +58,32 @@ function Invoke-ListCommunityRepoTemplates { # Community-imported template rows carry Source (repo FullName) and SHA (git blob sha), # which lets us mark imported/update-available without any extra GitHub calls. $TemplatesTable = Get-CippTable -tablename 'templates' - $ImportedTemplates = foreach ($Row in (Get-CIPPAzDataTableEntity @TemplatesTable)) { - if ([string]::IsNullOrEmpty($Row.Source)) { continue } - $Name = $null - try { - $Data = $Row.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop - $Name = $Data.displayName ?? $Data.Displayname ?? $Data.templateName ?? $Data.name - } catch { - # Name matching is best-effort; SHA matching still works without it - } - [PSCustomObject]@{ - Source = $Row.Source - SHA = $Row.SHA - SanitizedName = if ($Name) { ($Name -replace '\s', '_' -replace '[^\w\d_]', '') } else { $null } - } - } + $ImportedTemplates = @(foreach ($Row in (Get-CIPPAzDataTableEntity @TemplatesTable)) { + if ([string]::IsNullOrEmpty($Row.Source)) { continue } + $Name = $null + try { + $Data = $Row.JSON | ConvertFrom-Json -Depth 100 -ErrorAction Stop + $Name = $Data.displayName ?? $Data.Displayname ?? $Data.templateName ?? $Data.name + } catch { + # Name matching is best-effort; SHA matching still works without it + } + [PSCustomObject]@{ + Source = $Row.Source + SHA = $Row.SHA + SanitizedName = if ($Name) { ($Name -replace '\s', '_' -replace '[^\w\d_]', '') } else { $null } + } + }) + # Imported baselines live on BaselineRollouts, not the templates table - join them the + # same way so BaselineTemplate catalog items get Imported/UpdateAvailable badges. + $RolloutTable = Get-CippTable -tablename 'BaselineRollouts' + $ImportedTemplates = $ImportedTemplates + @(foreach ($Row in (Get-CIPPAzDataTableEntity @RolloutTable -Filter "PartitionKey eq 'rollout'")) { + if ([string]::IsNullOrEmpty($Row.Source)) { continue } + [PSCustomObject]@{ + Source = $Row.Source + SHA = $Row.SHA + SanitizedName = if ($Row.templateName) { ("$($Row.templateName)" -replace '\s', '_' -replace '[^\w\d_]', '') } else { $null } + } + }) # Folder names in CIPP-native repos are templates-table PartitionKeys ("Save to GitHub" # writes {PartitionKey}/{Name}.json), so these folder names reliably identify the type. @@ -82,7 +93,7 @@ function Invoke-ListCommunityRepoTemplates { 'ExConnectorTemplate', 'AppTemplate', 'ContactTemplate', 'JITAdminTemplate', 'UserDefaultTemplate', 'AssignmentFilterTemplate', 'IntuneReusableSettingTemplate', 'SharePointTemplate', 'DlpCompliancePolicyTemplate', 'RetentionCompliancePolicyTemplate', - 'SensitivityLabelTemplate', 'SensitiveInfoTypeTemplate' + 'SensitivityLabelTemplate', 'SensitiveInfoTypeTemplate', 'BaselineTemplate' ) $Warnings = [System.Collections.Generic.List[string]]::new() diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 index 179a8bd3dc54c..681107df31a54 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ListGitHubReleaseNotes.ps1 @@ -3,8 +3,9 @@ .SYNOPSIS Retrieves release notes for a GitHub repository. .DESCRIPTION - Returns release metadata for the provided repository and semantic version. Hotfix - versions (e.g. v8.5.2) map back to the base release tag (v8.5.0). + Returns release metadata for the provided repository. Results are cached and refreshed + when the cache has no entry for the running version - hotfix releases (e.g. v8.5.2) + publish their own notes, so a v8.5.0 entry no longer counts as current. .FUNCTIONALITY Entrypoint,AnyTenant .ROLE @@ -34,26 +35,20 @@ try { $Latest = $false if ($Rows) { - $Releases = ConvertFrom-Json -InputObject $Rows.GitHubReleases -Depth 10 - $CurrentVersion = [semver]($env:CippVersion ?? $env:APP_VERSION) - $CurrentMajorMinor = "$($CurrentVersion.Major).$($CurrentVersion.Minor)" + $CachedRow = $Rows | Select-Object -First 1 + $Releases = ConvertFrom-Json -InputObject $CachedRow.GitHubReleases -Depth 10 + $CurrentTag = 'v{0}' -f (($env:CippVersion ?? $env:APP_VERSION) -replace '^v', '') - foreach ($Release in $Releases) { - $Version = $Release.releaseTag -replace 'v', '' - try { - $ReleaseVersion = [semver]$Version - $ReleaseMajorMinor = "$($ReleaseVersion.Major).$($ReleaseVersion.Minor)" + # Hotfixes publish their own release, so the cache is only current when it holds an + # entry for the exact running version. Matching on major.minor kept serving v10.8.0's + # notes to a v10.8.2 build forever, because the .0 release is always in the cache. + $HasCurrentRelease = $null -ne ($Releases | Where-Object { $_.releaseTag -eq $CurrentTag } | Select-Object -First 1) - # Check if we have cached notes for the current major.minor version series - if ($ReleaseMajorMinor -eq $CurrentMajorMinor) { - $Latest = $true - break - } - } catch { - # Skip invalid semver versions - continue - } - } + # Versions with no release of their own (nightly, local, or a bump that lands before + # the tag is published) would otherwise refetch on every page load, so they fall back + # to a 15 minute floor instead of hammering the GitHub API. + $MaxAge = $HasCurrentRelease ? (Get-Date).AddHours(-24) : (Get-Date).AddMinutes(-15) + $Latest = $CachedRow.Timestamp -gt $MaxAge } if (-not $Latest) { diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index 20c91e022b902..3281c2b1d61b1 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -56,6 +56,13 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $Table = Get-CippTable -tablename 'templates' + # The template picker surfaces the row's GUID column, while everything here resolves by RowKey. + # CIPP writes both to the same value, so match either rather than reporting a template that is + # sitting in the table as missing. + $TemplateKey = ConvertTo-CIPPODataFilterValue -Value $Settings.TemplateList.value -Type String + $TemplateRow = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CATemplate' and (RowKey eq '$TemplateKey' or GUID eq '$TemplateKey')" | Select-Object -First 1 + $JSONObj = $TemplateRow.JSON + try { #Get from DB, as we just downloaded the latest before the standard runs. $AllCAPolicies = New-CIPPDbRequest -TenantFilter $tenant -Type 'ConditionalAccessPolicies' @@ -68,11 +75,9 @@ function Invoke-CIPPStandardConditionalAccessTemplate { } $DeployError = $null - if ($Settings.remediate -eq $true) { + if ($Settings.remediate -eq $true -and $JSONObj) { try { - $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" - $JSONObj = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON - $Policy = $JSONObj | ConvertFrom-Json + $Policy = $JSONObj | ConvertFrom-Json -Depth 100 if ($Policy.conditions.userRiskLevels.count -gt 0 -or $Policy.conditions.signInRiskLevels.count -gt 0) { $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -Preset EntraP2 -SkipLog if (!$TestP2) { @@ -107,8 +112,9 @@ function Invoke-CIPPStandardConditionalAccessTemplate { } if ($Settings.report -eq $true -or $Settings.remediate -eq $true) { $FieldName = "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" - $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" - $Policy = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON | ConvertFrom-Json -Depth 10 + # Guarded: piping a null blob into ConvertFrom-Json writes a binding error to the stream + # before landing on the same null, which buries the actionable message logged below. + $Policy = if ($JSONObj) { $JSONObj | ConvertFrom-Json -Depth 100 } else { $null } if ($null -eq $Policy) { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($Settings.TemplateList.label)' ($($Settings.TemplateList.value)) could not be loaded from the template store - skipping." -Sev 'Error' diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1 index 706048732fac1..bd04a38fc7817 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1 @@ -80,25 +80,41 @@ function Invoke-CIPPStandardDisableExchangeOnlinePowerShell { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Started disabling Exchange Online PowerShell for $PowerShellEnabledCount users." -sev Info $Request = foreach ($User in $UsersWithPowerShell) { + # Set-User returns no body on success, and New-ExoBulkRequest only synthesises a + # { Success = $true } record when an OperationGuid was supplied. Without one every + # successful user came back as nothing at all, so a fully successful run still + # reported "0 out of N" with no errors to explain it. The UPN doubles as the batch + # correlation id so successes are counted and failures are attributable. + $Identity = if ($User.Guid) { $User.Guid } else { $User.UPN } @{ - CmdletInput = @{ + OperationGuid = $User.UPN + CmdletInput = @{ CmdletName = 'Set-User' - Parameters = @{Identity = $User.Guid ?? $User.UPN; RemotePowerShellEnabled = $false } + Parameters = @{Identity = $Identity; RemotePowerShellEnabled = $false } } } } - $BatchResults = New-ExoBulkRequest -tenantid $tenant -cmdletArray @($Request) + $BatchResults = @(New-ExoBulkRequest -tenantid $tenant -cmdletArray @($Request)) $SuccessCount = 0 foreach ($Result in $BatchResults) { if ($Result.error) { $ErrorMessage = Get-NormalizedError -Message $Result.error - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to disable Exchange Online PowerShell for $($Result.target). Error: $ErrorMessage" -sev Error + # Prefer our own correlation id: it is the UPN, whereas Exchange's target + # echoes back the Identity we sent, which is usually an opaque GUID. + $Target = if ($Result.OperationGuid) { $Result.OperationGuid } else { $Result.target } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to disable Exchange Online PowerShell for $Target. Error: $ErrorMessage" -sev Error } else { $SuccessCount++ } } + # A short batch means users were neither confirmed nor reported as failed. Say so + # rather than letting them disappear into the summary count. + if ($BatchResults.Count -lt $PowerShellEnabledCount) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Exchange returned $($BatchResults.Count) results for $PowerShellEnabledCount requested users. $($PowerShellEnabledCount - $BatchResults.Count) user(s) were neither confirmed nor reported as failed." -sev Warning + } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully disabled Exchange Online PowerShell for $SuccessCount out of $PowerShellEnabledCount users." -sev Info } else { Write-LogMessage -API 'Standards' -tenant $tenant -message 'Exchange Online PowerShell is already disabled for all non-admin users' -sev Info diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableM365GroupUsers.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableM365GroupUsers.ps1 index f7c66ee3574d7..07346db2136bd 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableM365GroupUsers.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableM365GroupUsers.ps1 @@ -48,6 +48,11 @@ function Invoke-CIPPStandardDisableM365GroupUsers { return $true } #we're done. + $GroupUnifiedTemplateId = '62375ab9-6b52-47ed-826b-58e47e0e304b' + # Offline copy of the Group.Unified template, shaped like the live one ({name, defaultValue}). + # Only used when the template endpoint cannot be reached. + $GroupUnifiedFallbackTemplate = '[{"name":"NewUnifiedGroupWritebackDefault","defaultValue":"true"},{"name":"EnableMIPLabels","defaultValue":"false"},{"name":"CustomBlockedWordsList","defaultValue":""},{"name":"EnableMSStandardBlockedWords","defaultValue":"false"},{"name":"ClassificationDescriptions","defaultValue":""},{"name":"DefaultClassification","defaultValue":""},{"name":"PrefixSuffixNamingRequirement","defaultValue":""},{"name":"AllowGuestsToBeGroupOwner","defaultValue":"false"},{"name":"AllowGuestsToAccessGroups","defaultValue":"true"},{"name":"GuestUsageGuidelinesUrl","defaultValue":""},{"name":"GroupCreationAllowedGroupId","defaultValue":""},{"name":"AllowToAddGuests","defaultValue":"true"},{"name":"UsageGuidelinesUrl","defaultValue":""},{"name":"ClassificationList","defaultValue":""},{"name":"EnableGroupCreation","defaultValue":"true"}]' + try { $CurrentState = (New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/settings' -tenantid $tenant) | Where-Object -Property displayname -EQ 'Group.unified' @@ -109,25 +114,73 @@ function Invoke-CIPPStandardDisableM365GroupUsers { } if (!$CurrentState) { - # If no current configuration is found, we set it to the default template supplied by MS. - $CurrentState = '{"id":"","displayName":"Group.Unified","templateId":"62375ab9-6b52-47ed-826b-58e47e0e304b","values":[{"name":"NewUnifiedGroupWritebackDefault","value":"true"},{"name":"EnableMIPLabels","value":"false"},{"name":"CustomBlockedWordsList","value":""},{"name":"EnableMSStandardBlockedWords","value":"false"},{"name":"ClassificationDescriptions","value":""},{"name":"DefaultClassification","value":""},{"name":"PrefixSuffixNamingRequirement","value":""},{"name":"AllowGuestsToBeGroupOwner","value":"false"},{"name":"AllowGuestsToAccessGroups","value":"true"},{"name":"GuestUsageGuidelinesUrl","value":""},{"name":"GroupCreationAllowedGroupId","value":""},{"name":"AllowToAddGuests","value":"true"},{"name":"UsageGuidelinesUrl","value":""},{"name":"ClassificationList","value":""},{"name":"EnableGroupCreation","value":"true"}]}' - New-GraphPostRequest -tenantid $tenant -Uri "https://graph.microsoft.com/beta/settings/$($CurrentState.id)" -AsApp $true -Type POST -Body $CurrentState -ContentType 'application/json' - $CurrentState = (New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/settings' -tenantid $tenant) | Where-Object -Property displayname -EQ 'Group.unified' - } - ($CurrentState.values | Where-Object { $_.name -eq 'EnableGroupCreation' }).value = 'false' - if ($DesiredGroupId) { - ($CurrentState.values | Where-Object { $_.name -eq 'GroupCreationAllowedGroupId' }).value = "$DesiredGroupId" - } - $body = "{values : $($CurrentState.values | ConvertTo-Json -Compress)}" - $null = New-GraphPostRequest -tenantid $tenant -asApp $true -Uri "https://graph.microsoft.com/beta/settings/$($CurrentState.id)" -Type patch -Body $body -ContentType 'application/json' - if ($DesiredGroupId) { - Write-LogMessage -API 'Standards' -tenant $tenant -message "Disabled users from creating M365 Groups. Members of '$AllowedGroupName' remain allowed to create groups." -sev Info + # The tenant has no Group.Unified directory setting yet, so create it with the + # values we want already in place. Reading /beta/settings straight back after a + # write returns nothing for ~10s (measured), so the old create-then-reread-then- + # patch sequence left $CurrentState null and blew up on the assignment below. + # New-GraphPostRequest hands back the created object, so no read-back is needed. + # Prefer the live template so the payload stays complete if Microsoft revises + # it; fall back to the values Microsoft shipped when this standard was written. + try { + $Template = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/directorySettingTemplates/$GroupUnifiedTemplateId" -tenantid $Tenant + $TemplateValues = @($Template.values) + } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Could not read the Group.Unified directory setting template, using the built-in defaults. Error: $(Get-NormalizedError -Message $_.Exception.Message)" -sev Warning + $TemplateValues = @() + } + if (@($TemplateValues).Count -eq 0) { + $TemplateValues = @($GroupUnifiedFallbackTemplate | ConvertFrom-Json) + } + + $CreateValues = [System.Collections.Generic.List[object]]::new() + foreach ($TemplateValue in $TemplateValues) { + $NewValue = switch ($TemplateValue.name) { + 'EnableGroupCreation' { 'false' } + 'GroupCreationAllowedGroupId' { if ($DesiredGroupId) { "$DesiredGroupId" } else { $TemplateValue.defaultValue } } + default { $TemplateValue.defaultValue } + } + $CreateValues.Add(@{ name = $TemplateValue.name; value = $NewValue }) + } + $CreateBody = ConvertTo-Json -Depth 10 -Compress -InputObject @{ + templateId = $GroupUnifiedTemplateId + values = @($CreateValues) + } + $CurrentState = New-GraphPostRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/beta/settings' -AsApp $true -Type POST -Body $CreateBody -ContentType 'application/json' + if ($DesiredGroupId) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Created the Group.Unified directory setting with group creation disabled. Members of '$AllowedGroupName' remain allowed to create groups." -sev Info + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Created the Group.Unified directory setting with group creation disabled.' -sev Info + } + } elseif (!$CurrentState.id -or @($CurrentState.values).Count -eq 0) { + # Guard against a partial read. PATCH replaces the whole values collection, so + # patching a truncated response would wipe the tenant's naming policy, guest + # access and classification settings. Bail loudly instead. + Write-LogMessage -API 'Standards' -tenant $Tenant -message "The Group.Unified directory setting for $Tenant came back incomplete (id: '$($CurrentState.id)', values: $(@($CurrentState.values).Count)). Skipping remediation rather than overwriting the existing group settings." -sev Error } else { - Write-LogMessage -API 'Standards' -tenant $tenant -message 'Disabled users from creating M365 Groups.' -sev Info + # Rebuild the values collection instead of assigning onto the match: tenants + # whose setting predates a template revision can be missing an entry entirely, + # and assigning .value onto a null match is what produced the reported error. + $DesiredValues = [System.Collections.Generic.List[object]]::new() + foreach ($Value in @($CurrentState.values)) { + if ($Value.name -eq 'EnableGroupCreation') { continue } + if ($DesiredGroupId -and $Value.name -eq 'GroupCreationAllowedGroupId') { continue } + $DesiredValues.Add(@{ name = $Value.name; value = $Value.value }) + } + $DesiredValues.Add(@{ name = 'EnableGroupCreation'; value = 'false' }) + if ($DesiredGroupId) { + $DesiredValues.Add(@{ name = 'GroupCreationAllowedGroupId'; value = "$DesiredGroupId" }) + } + $Body = ConvertTo-Json -Depth 10 -Compress -InputObject @{ values = @($DesiredValues) } + $null = New-GraphPostRequest -tenantid $tenant -asApp $true -Uri "https://graph.microsoft.com/beta/settings/$($CurrentState.id)" -Type patch -Body $Body -ContentType 'application/json' + if ($DesiredGroupId) { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Disabled users from creating M365 Groups. Members of '$AllowedGroupName' remain allowed to create groups." -sev Info + } else { + Write-LogMessage -API 'Standards' -tenant $tenant -message 'Disabled users from creating M365 Groups.' -sev Info + } } } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to disable users from creating M365 Groups: $ErrorMessage" -sev 'Error' + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to disable users from creating M365 Groups: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage } } } diff --git a/Tests/DBCache/Set-CIPPDBCache.Memory.Tests.ps1 b/Tests/DBCache/Set-CIPPDBCache.Memory.Tests.ps1 index 5ee506088c107..5dce87099fa6f 100644 --- a/Tests/DBCache/Set-CIPPDBCache.Memory.Tests.ps1 +++ b/Tests/DBCache/Set-CIPPDBCache.Memory.Tests.ps1 @@ -48,19 +48,23 @@ BeforeAll { [int]$SkewMarginMinutes = 5 ) begin { - $script:DbWrites.Add([pscustomobject]@{ - Type = $Type - Tenant = $TenantFilter - AddCount = $AddCount.IsPresent - Rows = [System.Collections.Generic.List[object]]::new() - }) + # EndRan is what proves the count row / orphan cleanup would have happened: collectors + # that open their writer up front still must not call End() on an empty result. + $Entry = [pscustomobject]@{ + Type = $Type + Tenant = $TenantFilter + AddCount = $AddCount.IsPresent + Rows = [System.Collections.Generic.List[object]]::new() + EndRan = $false + } + $script:DbWrites.Add($Entry) } process { foreach ($Item in @($InputObject)) { - if ($null -ne $Item) { $script:DbWrites[-1].Rows.Add($Item) } + if ($null -ne $Item) { $Entry.Rows.Add($Item) } } } - end { } + end { $Entry.EndRan = $true } } function New-BulkReportResponse { @@ -443,14 +447,18 @@ Describe 'DBCache collectors reworked for bounded memory' { $script:DbWrites[0].Rows.id | Should -Be @('u1', 'u2') } - It 'does not invoke the writer at all when the report is empty' { - # The pre-existing behaviour: an empty report leaves the previous cache and its count row - # untouched. Piping unconditionally would have run the writer's end block and zeroed it. + It 'does not run the writer end block when the report is empty' { + # An empty report leaves the previous cache and its count row untouched. The writer is + # opened before the Graph pipeline starts - its steppable pipeline has to capture this + # collector's scope rather than New-GraphGetRequest's - so the invocation itself is + # expected. What must not happen is End(), which writes the count row and clears orphans. Mock -CommandName New-GraphGetRequest -MockWith { @() } Set-CIPPDBCacheUserRegistrationDetails -TenantFilter 'contoso.com' - $script:DbWrites.Count | Should -Be 0 + $script:DbWrites.Count | Should -Be 1 + $script:DbWrites[0].Rows | Should -BeNullOrEmpty + $script:DbWrites[0].EndRan | Should -BeFalse } } diff --git a/Tests/Private/Get-CIPPSiteHostname.Tests.ps1 b/Tests/Private/Get-CIPPSiteHostname.Tests.ps1 index a0f4757789aab..1ceefe5ae590b 100644 --- a/Tests/Private/Get-CIPPSiteHostname.Tests.ps1 +++ b/Tests/Private/Get-CIPPSiteHostname.Tests.ps1 @@ -48,7 +48,8 @@ Describe 'Get-CIPPSiteHostname' { Mock -CommandName New-CIPPAzRestRequest -MockWith { [PSCustomObject]@{ properties = [PSCustomObject]@{ - hostNames = @($script:DefaultHost, $script:CustomHostA, $script:CustomHostB) + hostNames = @($script:DefaultHost, $script:CustomHostA, $script:CustomHostB) + defaultHostName = $script:DefaultHost } } } @@ -102,6 +103,95 @@ Describe 'Get-CIPPSiteHostname' { } } + Context 'When picking the domain user-facing URLs should use' { + It 'separates the custom domains from the hostname Azure handed out' { + $Result = Get-CIPPSiteHostname -IncludeStatus + + $Result.DefaultHostname | Should -Be $script:DefaultHost + $Result.CustomHostnames | Should -HaveCount 2 + $Result.CustomHostnames | Should -Not -Contain $script:DefaultHost + } + + It 'prefers the first custom domain when several are bound' { + # Warmup runs on every node and they all have to land on the same answer, so the choice + # has to be positional rather than "whichever one came back first this time". + $Result = Get-CIPPSiteHostname -IncludeStatus + + $Result.PreferredHostname | Should -Be $script:CustomHostA + } + + It 'falls back to the platform hostname when no custom domain is bound' { + Mock -CommandName New-CIPPAzRestRequest -MockWith { + [PSCustomObject]@{ + properties = [PSCustomObject]@{ + hostNames = @($script:DefaultHost) + defaultHostName = $script:DefaultHost + } + } + } + + $Result = Get-CIPPSiteHostname -IncludeStatus + + $Result.CustomHostnames | Should -HaveCount 0 + $Result.PreferredHostname | Should -Be $script:DefaultHost + } + + It 'treats a regional *.azurewebsites.net hostname as the platform one even when ARM omits defaultHostName' { + $script:RegionalHost = 'cippxyz-abc123.centralus-01.azurewebsites.net' + Mock -CommandName New-CIPPAzRestRequest -MockWith { + [PSCustomObject]@{ + properties = [PSCustomObject]@{ + hostNames = @($script:RegionalHost, $script:CustomHostA) + } + } + } + + $Result = Get-CIPPSiteHostname -IncludeStatus + + $Result.CustomHostnames | Should -Be @($script:CustomHostA) + $Result.PreferredHostname | Should -Be $script:CustomHostA + } + } + + Context 'When -NoFallback is set' { + # The fallback reads the stored instance URL, which is the very value the reconciler is + # trying to verify. Feeding it back would make every stale value look self-consistent. + BeforeEach { + Mock -CommandName New-CIPPAzRestRequest -MockWith { throw 'AuthorizationFailed' } + } + + It 'returns nothing instead of a best-effort list' { + $Result = Get-CIPPSiteHostname -IncludeStatus -NoFallback + + $Result.Discovered | Should -BeFalse + $Result.Hostnames | Should -HaveCount 0 + $Result.CustomHostnames | Should -HaveCount 0 + Should -Invoke Get-CIPPHostname -Times 0 -Exactly + } + + It 'still surfaces why the lookup failed' { + $Result = Get-CIPPSiteHostname -IncludeStatus -NoFallback + + $Result.Error | Should -Match 'AuthorizationFailed' + } + + It 'does not suppress the fallback when ARM answered' { + Mock -CommandName New-CIPPAzRestRequest -MockWith { + [PSCustomObject]@{ + properties = [PSCustomObject]@{ + hostNames = @($script:DefaultHost, $script:CustomHostA) + defaultHostName = $script:DefaultHost + } + } + } + + $Result = Get-CIPPSiteHostname -IncludeStatus -NoFallback + + $Result.Discovered | Should -BeTrue + $Result.PreferredHostname | Should -Be $script:CustomHostA + } + } + Context 'When the domains cannot be queried' { It 'flags Discovered = false when ARM throws, and surfaces the reason' { Mock -CommandName New-CIPPAzRestRequest -MockWith { throw 'Azure REST API call failed: AuthorizationFailed' } diff --git a/Tests/Private/Update-CIPPInstanceHostname.Tests.ps1 b/Tests/Private/Update-CIPPInstanceHostname.Tests.ps1 new file mode 100644 index 0000000000000..067615243de0d --- /dev/null +++ b/Tests/Private/Update-CIPPInstanceHostname.Tests.ps1 @@ -0,0 +1,198 @@ +# Pester tests for Update-CIPPInstanceHostname +# Config/InstanceProperties/CIPPURL is what background jobs build links from and what the Partner +# Center webhook is registered against, so warmup correcting it is only safe if it can tell "the +# custom domain changed" from "ARM did not answer". The tests that matter are the ones proving it +# writes nothing on an inconclusive lookup - a bad write here silently breaks every emailed link. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Functions/Update-CIPPInstanceHostname.ps1' + + # Minimal stubs so Mock has commands to replace during tests + function Get-CIPPSiteHostname { param([switch]$AsRedirectUri, [switch]$IncludeStatus, [switch]$NoFallback) } + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + + . $FunctionPath +} + +Describe 'Update-CIPPInstanceHostname' { + BeforeEach { + $script:DefaultHost = 'cippxyz.azurewebsites.net' + $script:CustomHostA = 'cipp.contoso.com' + $script:CustomHostB = 'portal.fabrikam.com' + $script:StoredValue = $script:DefaultHost + + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'Config' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + if ($null -eq $script:StoredValue) { return $null } + [PSCustomObject]@{ + PartitionKey = 'InstanceProperties' + RowKey = 'CIPPURL' + Value = $script:StoredValue + } + } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Get-CIPPSiteHostname -MockWith { + [PSCustomObject]@{ + Hostnames = @($script:DefaultHost, $script:CustomHostA) + DefaultHostname = $script:DefaultHost + CustomHostnames = @($script:CustomHostA) + PreferredHostname = $script:CustomHostA + Discovered = $true + Error = $null + } + } + } + + Context 'When the stored URL has drifted from the bound custom domain' { + It 'writes the custom domain to Config/InstanceProperties/CIPPURL' { + $Result = Update-CIPPInstanceHostname + + $Result.Updated | Should -BeTrue + $Result.ResolvedHostname | Should -Be $script:CustomHostA + $Result.StoredHostname | Should -Be $script:DefaultHost + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + $Entity.PartitionKey -eq 'InstanceProperties' -and + $Entity.RowKey -eq 'CIPPURL' -and + $Entity.Value -eq $script:CustomHostA + } + } + + It 'writes when nothing has ever been stored' { + $script:StoredValue = $null + + $Result = Update-CIPPInstanceHostname + + $Result.Updated | Should -BeTrue + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly + } + + It 'demotes to the platform hostname once the custom domain is unbound' { + # The reverse case is real: a customer removes the custom domain and the stored value + # keeps pointing at a hostname that no longer resolves to this instance. + $script:StoredValue = $script:CustomHostA + Mock -CommandName Get-CIPPSiteHostname -MockWith { + [PSCustomObject]@{ + Hostnames = @($script:DefaultHost) + DefaultHostname = $script:DefaultHost + CustomHostnames = @() + PreferredHostname = $script:DefaultHost + Discovered = $true + Error = $null + } + } + + $Result = Update-CIPPInstanceHostname + + $Result.Updated | Should -BeTrue + $Result.ResolvedHostname | Should -Be $script:DefaultHost + } + + It 'takes the first custom domain when several are bound' { + Mock -CommandName Get-CIPPSiteHostname -MockWith { + [PSCustomObject]@{ + Hostnames = @($script:DefaultHost, $script:CustomHostA, $script:CustomHostB) + DefaultHostname = $script:DefaultHost + CustomHostnames = @($script:CustomHostA, $script:CustomHostB) + PreferredHostname = $script:CustomHostA + Discovered = $true + Error = $null + } + } + + $Result = Update-CIPPInstanceHostname + + $Result.ResolvedHostname | Should -Be $script:CustomHostA + $Result.CustomHostnames | Should -HaveCount 2 + } + } + + Context 'When the stored URL is already correct' { + It 'does not write' { + $script:StoredValue = $script:CustomHostA + + $Result = Update-CIPPInstanceHostname + + $Result.Updated | Should -BeFalse + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'ignores casing differences rather than rewriting every warmup' { + $script:StoredValue = $script:CustomHostA.ToUpper() + + $Result = Update-CIPPInstanceHostname + + $Result.Updated | Should -BeFalse + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + } + + Context 'When the bound hostnames cannot be enumerated' { + # This is the important one. An unreachable ARM is not evidence that the custom domain is + # gone, and writing the platform hostname here would break every link CIPP sends out until + # someone re-saved the automated onboarding page by hand. + It 'leaves the stored URL alone when discovery was not authoritative' { + Mock -CommandName Get-CIPPSiteHostname -MockWith { + [PSCustomObject]@{ + Hostnames = @() + DefaultHostname = $script:DefaultHost + CustomHostnames = @() + PreferredHostname = $script:DefaultHost + Discovered = $false + Error = 'AuthorizationFailed' + } + } + + $Result = Update-CIPPInstanceHostname + + $Result.Updated | Should -BeFalse + $Result.Reason | Should -Match 'AuthorizationFailed' + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'does not even read the stored value when discovery failed' { + Mock -CommandName Get-CIPPSiteHostname -MockWith { + [PSCustomObject]@{ Discovered = $false; CustomHostnames = @(); PreferredHostname = $null; Error = 'boom' } + } + + Update-CIPPInstanceHostname | Out-Null + + Should -Invoke Get-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'writes nothing when ARM answered but there is no usable hostname' { + # Local development: no App Service, so the list comes back empty by design. + Mock -CommandName Get-CIPPSiteHostname -MockWith { + [PSCustomObject]@{ Discovered = $true; CustomHostnames = @(); PreferredHostname = ''; Error = $null } + } + + $Result = Update-CIPPInstanceHostname + + $Result.Updated | Should -BeFalse + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + } + + Context 'When storage misbehaves' { + It 'never throws out of warmup' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { throw 'table storage unavailable' } + + { Update-CIPPInstanceHostname } | Should -Not -Throw + + $Result = Update-CIPPInstanceHostname + $Result.Updated | Should -BeFalse + $Result.Reason | Should -Match 'table storage unavailable' + } + + It 'reports the failure rather than claiming an update when the write throws' { + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { throw 'write conflict' } + + $Result = Update-CIPPInstanceHostname + + $Result.Updated | Should -BeFalse + $Result.Reason | Should -Match 'write conflict' + } + } +} diff --git a/Tests/Private/Update-CIPPPartnerWebhookUrl.Tests.ps1 b/Tests/Private/Update-CIPPPartnerWebhookUrl.Tests.ps1 new file mode 100644 index 0000000000000..b822efb64cc5b --- /dev/null +++ b/Tests/Private/Update-CIPPPartnerWebhookUrl.Tests.ps1 @@ -0,0 +1,212 @@ +# Pester tests for Update-CIPPPartnerWebhookUrl +# This is the one warmup step that writes to an external service, so most of what matters here is +# what it REFUSES to do. A wrong re-registration points Partner Center at an address CIPP is not +# served on, which stops tenant onboarding silently - nothing on the page fails, events just stop +# arriving. Every "does not write" test below guards a way that could happen. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Webhooks/Update-CIPPPartnerWebhookUrl.ps1' + + # Minimal stubs so Mock has commands to replace during tests + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Get-CIPPSiteHostname { param([switch]$AsRedirectUri, [switch]$IncludeStatus, [switch]$NoFallback) } + function New-GraphGetRequest { param($uri, $tenantid, $NoAuthCheck, $scope) } + function New-CIPPGraphSubscription { param($TenantFilter, $BaseURL, $EventType, $APIName, [switch]$PartnerCenter) } + + . $FunctionPath +} + +Describe 'Update-CIPPPartnerWebhookUrl' { + BeforeEach { + $script:CustomHostA = 'cipp.contoso.com' + $script:CustomHostB = 'portal.fabrikam.com' + $script:TenantId = 'b0bdb332-24b4-4b86-b054-d86f4b461da3' + $script:OriginalTenantId = $env:TenantID + $env:TenantID = $script:TenantId + + $script:Enabled = $true + $script:RegisteredUrl = "https://old.cipp.example/api/PublicWebhooks?CIPPID=$script:TenantId&Type=PartnerCenter" + $script:RegisteredEvents = @('test-created', 'granular-admin-relationship-approved', 'subscription-updated') + + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'Config' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + [PSCustomObject]@{ RowKey = 'PartnerWebhookOnboarding'; Enabled = $script:Enabled } + } + Mock -CommandName Get-CIPPSiteHostname -MockWith { + [PSCustomObject]@{ + CustomHostnames = @($script:CustomHostA) + PreferredHostname = $script:CustomHostA + Discovered = $true + Error = $null + } + } + Mock -CommandName New-GraphGetRequest -MockWith { + [PSCustomObject]@{ + webhookUrl = $script:RegisteredUrl + webhookEvents = $script:RegisteredEvents + } + } + Mock -CommandName New-CIPPGraphSubscription -MockWith { 'Updated Partner Center Webhook subscription' } + } + + AfterEach { + $env:TenantID = $script:OriginalTenantId + } + + Context 'When the subscription points at a stale URL' { + It 're-registers against the bound custom domain' { + $Result = Update-CIPPPartnerWebhookUrl + + $Result.Updated | Should -BeTrue + $Result.ExpectedUrl | Should -Be "https://$script:CustomHostA/api/PublicWebhooks?CIPPID=$script:TenantId&Type=PartnerCenter" + Should -Invoke New-CIPPGraphSubscription -Times 1 -Exactly -ParameterFilter { + $PartnerCenter -eq $true -and $BaseURL -eq $script:CustomHostA + } + } + + It 'carries the registered event types across' { + # Without this the repair silently narrows the subscription to the two events + # New-CIPPGraphSubscription always adds, dropping whatever the admin selected. + Update-CIPPPartnerWebhookUrl | Out-Null + + Should -Invoke New-CIPPGraphSubscription -Times 1 -Exactly -ParameterFilter { + $EventType -contains 'subscription-updated' + } + } + + It 'uses the first custom domain when several are bound' { + Mock -CommandName Get-CIPPSiteHostname -MockWith { + [PSCustomObject]@{ + CustomHostnames = @($script:CustomHostA, $script:CustomHostB) + PreferredHostname = $script:CustomHostA + Discovered = $true + Error = $null + } + } + + Update-CIPPPartnerWebhookUrl | Out-Null + + Should -Invoke New-CIPPGraphSubscription -Times 1 -Exactly -ParameterFilter { + $BaseURL -eq $script:CustomHostA + } + } + + It 'reports the failure rather than claiming success when the re-registration fails' { + Mock -CommandName New-CIPPGraphSubscription -MockWith { 'Failed to create Partner Webhook Subscription: 403' } + + $Result = Update-CIPPPartnerWebhookUrl + + $Result.Updated | Should -BeFalse + $Result.Reason | Should -Match '403' + } + } + + Context 'When the subscription is already correct' { + BeforeEach { + $script:RegisteredUrl = "https://$script:CustomHostA/api/PublicWebhooks?CIPPID=$script:TenantId&Type=PartnerCenter" + } + + It 'does not write' { + $Result = Update-CIPPPartnerWebhookUrl + + $Result.Updated | Should -BeFalse + Should -Invoke New-CIPPGraphSubscription -Times 0 -Exactly + } + + It 'ignores casing differences rather than re-registering every warmup' { + $script:RegisteredUrl = $script:RegisteredUrl.ToUpper() + + Update-CIPPPartnerWebhookUrl | Out-Null + + Should -Invoke New-CIPPGraphSubscription -Times 0 -Exactly + } + } + + Context 'When it must not touch the subscription at all' { + It 'does nothing when automated onboarding is disabled' { + $script:Enabled = $false + + $Result = Update-CIPPPartnerWebhookUrl + + $Result.Enabled | Should -BeFalse + Should -Invoke New-GraphGetRequest -Times 0 -Exactly + Should -Invoke New-CIPPGraphSubscription -Times 0 -Exactly + } + + It 'does nothing when the onboarding config has never been written' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $null } + + $Result = Update-CIPPPartnerWebhookUrl + + $Result.Enabled | Should -BeFalse + Should -Invoke New-CIPPGraphSubscription -Times 0 -Exactly + } + + It 'does nothing when the bound hostname is not authoritative' { + # The stored instance URL is not a safe substitute here: on a fresh container it is the + # platform hostname, so "repairing" from it would break a working custom domain. + Mock -CommandName Get-CIPPSiteHostname -MockWith { + [PSCustomObject]@{ + CustomHostnames = @() + PreferredHostname = $null + Discovered = $false + Error = 'Not running in App Service' + } + } + + $Result = Update-CIPPPartnerWebhookUrl + + $Result.Updated | Should -BeFalse + $Result.Reason | Should -Match 'App Service' + Should -Invoke New-GraphGetRequest -Times 0 -Exactly + Should -Invoke New-CIPPGraphSubscription -Times 0 -Exactly + } + + It 'does nothing when no partner tenant is configured' { + $env:TenantID = '' + + $Result = Update-CIPPPartnerWebhookUrl + + $Result.Updated | Should -BeFalse + Should -Invoke New-CIPPGraphSubscription -Times 0 -Exactly + } + + It 'does not write when the current subscription could not be read' { + # A failed read is not a mismatch. Falling through here would create a subscription on + # top of one we never managed to see. + Mock -CommandName New-GraphGetRequest -MockWith { throw 'Partner Center returned 403' } + + $Result = Update-CIPPPartnerWebhookUrl + + $Result.Updated | Should -BeFalse + $Result.Reason | Should -Match '403' + Should -Invoke New-CIPPGraphSubscription -Times 0 -Exactly + } + } + + Context 'When there is no subscription in Partner Center at all' { + It 'creates one, because onboarding is enabled and expects it to exist' { + Mock -CommandName New-GraphGetRequest -MockWith { $null } + Mock -CommandName New-CIPPGraphSubscription -MockWith { 'Created Partner Center Webhook subscription' } + + $Result = Update-CIPPPartnerWebhookUrl + + $Result.Updated | Should -BeTrue + Should -Invoke New-CIPPGraphSubscription -Times 1 -Exactly + } + } + + Context 'When something unexpected breaks' { + It 'never throws out of warmup' { + Mock -CommandName Get-CIPPTable -MockWith { throw 'table storage unavailable' } + + { Update-CIPPPartnerWebhookUrl } | Should -Not -Throw + + $Result = Update-CIPPPartnerWebhookUrl + $Result.Updated | Should -BeFalse + $Result.Reason | Should -Match 'table storage unavailable' + } + } +} diff --git a/Tests/Standards/Compare-CIPPIntuneObject.CA.Tests.ps1 b/Tests/Standards/Compare-CIPPIntuneObject.CA.Tests.ps1 new file mode 100644 index 0000000000000..8707167072201 --- /dev/null +++ b/Tests/Standards/Compare-CIPPIntuneObject.CA.Tests.ps1 @@ -0,0 +1,112 @@ +# Pester tests for Compare-CIPPIntuneObject -CompareType 'ca'. +# +# Regression under test: a CA template stores grantControls.authenticationStrength as a bare +# { id } reference, while Graph returns the strength fully expanded on read. The shared exclusion +# list drops id/createdDateTime/lastModifiedDateTime but not displayName/description/policyType/ +# requirementsSatisfied/allowedCombinations, so every drift run reported five phantom differences +# that remediation could never clear - and with auto-remediate on, PATCHed the policy every cycle. +# +# Both the ConditionalAccessTemplate standard and Invoke-CIPPCATemplateBatch reach this through +# -CompareType 'ca', so fixing it here covers both. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPIntuneCompareExclusions.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1') + + # The strength as Graph returns it inside a CA policy read. + function New-ExpandedStrength { + param( + $Id = '00000000-0000-0000-0000-000000000002', + $DisplayName = 'Multifactor authentication' + ) + [pscustomobject]@{ + id = $Id + createdDateTime = '2021-12-01T08:00:00Z' + modifiedDateTime = '2021-12-01T08:00:00Z' + displayName = $DisplayName + description = 'Combinations of methods that satisfy strong authentication, such as a password + SMS' + policyType = 'builtIn' + requirementsSatisfied = 'mfa' + allowedCombinations = @('windowsHelloForBusiness', 'fido2', 'x509CertificateMultiFactor', 'password,sms') + } + } + + function New-CAPolicy { + param($Strength, $State = 'enabled') + $GrantControls = [pscustomobject]@{ operator = 'OR'; builtInControls = @() } + if ($Strength) { + $GrantControls | Add-Member -NotePropertyName 'authenticationStrength' -NotePropertyValue $Strength + } + [pscustomobject]@{ + displayName = 'Require MFA for all Users' + state = $State + conditions = [pscustomobject]@{ + users = [pscustomobject]@{ includeUsers = @('All'); excludeUsers = @() } + applications = [pscustomobject]@{ includeApplications = @('All') } + } + grantControls = $GrantControls + } + } +} + +Describe "Compare-CIPPIntuneObject -CompareType 'ca'" { + It 'reports no drift for an id-only template against the expanded live policy' { + $Template = New-CAPolicy -Strength ([pscustomobject]@{ id = '00000000-0000-0000-0000-000000000002' }) + + $Compare = Compare-CIPPIntuneObject -ReferenceObject $Template -DifferenceObject (New-CAPolicy -Strength (New-ExpandedStrength)) -CompareType 'ca' + + $Compare | Should -BeNullOrEmpty + } + + It 'reports no drift when allowedCombinations come back in a different order' { + $LiveStrength = New-ExpandedStrength + $LiveStrength.allowedCombinations = @('password,sms', 'fido2', 'windowsHelloForBusiness', 'x509CertificateMultiFactor') + + $Compare = Compare-CIPPIntuneObject -ReferenceObject (New-CAPolicy -Strength (New-ExpandedStrength)) -DifferenceObject (New-CAPolicy -Strength $LiveStrength) -CompareType 'ca' + + $Compare | Should -BeNullOrEmpty + } + + It 'still reports drift when the tenant uses a different strength' { + $Template = New-CAPolicy -Strength (New-ExpandedStrength -Id '00000000-0000-0000-0000-000000000004' -DisplayName 'Phishing-resistant MFA') + + $Compare = @(Compare-CIPPIntuneObject -ReferenceObject $Template -DifferenceObject (New-CAPolicy -Strength (New-ExpandedStrength)) -CompareType 'ca') + + $Compare.Count | Should -Be 1 + $Compare[0].Property | Should -Be 'grantControls.authenticationStrength.displayName' + $Compare[0].ExpectedValue | Should -Be 'Phishing-resistant MFA' + $Compare[0].ReceivedValue | Should -Be 'Multifactor authentication' + } + + It 'still reports drift when the tenant policy dropped the strength entirely' { + $Compare = @(Compare-CIPPIntuneObject -ReferenceObject (New-CAPolicy -Strength (New-ExpandedStrength)) -DifferenceObject (New-CAPolicy) -CompareType 'ca') + + $Compare.Count | Should -Be 1 + $Compare[0].Property | Should -Be 'grantControls.authenticationStrength' + } + + It 'still reports genuine drift elsewhere in the policy' { + $Compare = @(Compare-CIPPIntuneObject -ReferenceObject (New-CAPolicy -Strength (New-ExpandedStrength) -State 'enabled') -DifferenceObject (New-CAPolicy -Strength (New-ExpandedStrength) -State 'disabled') -CompareType 'ca') + + $Compare.Count | Should -Be 1 + $Compare[0].Property | Should -Be 'state' + } + + It 'does not mutate the objects it was given' { + $Live = New-CAPolicy -Strength (New-ExpandedStrength) + + $null = Compare-CIPPIntuneObject -ReferenceObject (New-CAPolicy -Strength (New-ExpandedStrength)) -DifferenceObject $Live -CompareType 'ca' + + $Live.grantControls.authenticationStrength.allowedCombinations | Should -Not -BeNullOrEmpty + $Live.grantControls.authenticationStrength.policyType | Should -Be 'builtIn' + } + + It 'leaves comparisons that did not opt in alone' { + $Template = New-CAPolicy -Strength ([pscustomobject]@{ id = '00000000-0000-0000-0000-000000000002' }) + + $Compare = @(Compare-CIPPIntuneObject -ReferenceObject $Template -DifferenceObject (New-CAPolicy -Strength (New-ExpandedStrength))) + + $Compare.Property | Should -Contain 'grantControls.authenticationStrength.allowedCombinations' + } +} diff --git a/Tests/Standards/Compare-CIPPIntuneObject.Catalog.Tests.ps1 b/Tests/Standards/Compare-CIPPIntuneObject.Catalog.Tests.ps1 new file mode 100644 index 0000000000000..33a5fd4667ca1 --- /dev/null +++ b/Tests/Standards/Compare-CIPPIntuneObject.Catalog.Tests.ps1 @@ -0,0 +1,194 @@ +# Pester tests for the Catalog branch of Compare-CIPPIntuneObject and the definition index +# behind it. +# +# Regression under test: the Catalog branch read and parsed Config/intuneCollection.json - 18MB, +# ~18,000 definitions - on every call, which is once per Intune template, per tenant. Each parse +# retained several hundred MB of managed heap, and eight background workers comparing concurrently +# cleared the container's 2,398MB DOTNET_GCHeapHardLimit. The comparison then died with an +# OutOfMemoryException that Invoke-CIPPStandardIntuneTemplate caught and reported to the technician +# as drift ("Comparison failed: Exception of type 'System.OutOfMemoryException' was thrown."). +# +# Get-CIPPIntuneDefinitionIndex now projects the two things a comparison actually uses - the +# display name and the id -> display name option map - once per process. These tests pin the +# caching, the invalidation, and the label resolution the option lookup replaced. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPIntuneDefinitionIndex.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1') + + # Point CIPPRootPath at a throwaway tree holding a small stand-in collection, so these tests do + # not depend on the shipped 18MB file. The target path is built with the same expression the + # function reads with, and the parent is derived from that string, so the test and the function + # agree on where the file lives on both Windows and Linux. + $script:FakeRoot = Join-Path ([System.IO.Path]::GetTempPath()) "cipp-defidx-$([guid]::NewGuid())" + $script:CollectionPath = "$script:FakeRoot\Config\intuneCollection.json" + New-Item -ItemType Directory -Path (Split-Path -Parent $script:CollectionPath) -Force | Out-Null + $env:CIPPRootPath = $script:FakeRoot + + function Set-FakeCollection { + param([string]$ToggleLabel = 'Toggle Setting', [string]$OnLabel = 'On') + $Collection = @( + @{ id = 'setting_toggle'; displayName = $ToggleLabel; description = 'x' * 200 + options = @(@{ id = 'setting_toggle_0'; displayName = 'Off' }, @{ id = 'setting_toggle_1'; displayName = $OnLabel }) + } + @{ id = 'setting_plain'; displayName = 'Plain Setting'; helpText = 'y' * 200 } + @{ id = 'setting_multi'; displayName = 'Multi Setting' + options = @(@{ id = 'setting_multi_a'; displayName = 'Alpha' }, @{ id = 'setting_multi_b'; displayName = 'Bravo' }) + } + ) + [System.IO.File]::WriteAllText($script:CollectionPath, ($Collection | ConvertTo-Json -Depth 10 -Compress)) + # The cache is keyed on length and last write time; clear it so each test starts honest. + Remove-Variable -Name CIPPIntuneDefinitionIndex, CIPPIntuneDefinitionIndexStamp -Scope Script -ErrorAction SilentlyContinue + } + + function New-CatalogSetting { + param($DefinitionId, $Type, $Value) + switch ($Type) { + 'choice' { @{ settingInstance = @{ '@odata.type' = '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance' + settingDefinitionId = $DefinitionId; choiceSettingValue = @{ value = $Value; children = @() } } } } + 'simple' { @{ settingInstance = @{ '@odata.type' = '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance' + settingDefinitionId = $DefinitionId; simpleSettingValue = @{ value = $Value } } } } + 'choiceCollection' { @{ settingInstance = @{ '@odata.type' = '#microsoft.graph.deviceManagementConfigurationChoiceSettingCollectionInstance' + settingDefinitionId = $DefinitionId; choiceSettingCollectionValue = @($Value | ForEach-Object { @{ value = $_ } }) } } } + } + } + + function New-CatalogPolicy { + param([object[]]$Settings) + [pscustomobject]@{ settings = @($Settings) } + } +} + +AfterAll { + Remove-Item -LiteralPath $script:FakeRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-CIPPIntuneDefinitionIndex' { + BeforeEach { Set-FakeCollection } + + It 'indexes every definition by id' { + $Index = Get-CIPPIntuneDefinitionIndex + $Index.Count | Should -Be 3 + $Index['setting_plain'].displayName | Should -Be 'Plain Setting' + } + + It 'projects options as an id -> display name map' { + $Index = Get-CIPPIntuneDefinitionIndex + $Index['setting_toggle'].options['setting_toggle_1'] | Should -Be 'On' + $Index['setting_toggle'].options['setting_toggle_0'] | Should -Be 'Off' + } + + It 'leaves options null for definitions that carry none' { + (Get-CIPPIntuneDefinitionIndex)['setting_plain'].options | Should -BeNullOrEmpty + } + + It 'matches ids case-insensitively, the way the -eq comparisons it replaced did' { + (Get-CIPPIntuneDefinitionIndex)['SETTING_TOGGLE'].displayName | Should -Be 'Toggle Setting' + } + + It 'returns null for a missing key rather than throwing' { + { (Get-CIPPIntuneDefinitionIndex)['not_a_real_definition'] } | Should -Not -Throw + (Get-CIPPIntuneDefinitionIndex)['not_a_real_definition'] | Should -BeNullOrEmpty + } + + It 'hands back the same instance on a second call instead of reparsing' { + $First = Get-CIPPIntuneDefinitionIndex + $Second = Get-CIPPIntuneDefinitionIndex + [object]::ReferenceEquals($First, $Second) | Should -BeTrue + } + + It 'rebuilds when the collection on disk changes' { + (Get-CIPPIntuneDefinitionIndex)['setting_toggle'].displayName | Should -Be 'Toggle Setting' + Set-FakeCollection -ToggleLabel 'Renamed Toggle Setting' + (Get-CIPPIntuneDefinitionIndex)['setting_toggle'].displayName | Should -Be 'Renamed Toggle Setting' + } + + It 'returns null when the collection is missing instead of throwing' { + $env:CIPPRootPath = Join-Path ([System.IO.Path]::GetTempPath()) "cipp-defidx-absent-$([guid]::NewGuid())" + Remove-Variable -Name CIPPIntuneDefinitionIndex, CIPPIntuneDefinitionIndexStamp -Scope Script -ErrorAction SilentlyContinue + try { + Get-CIPPIntuneDefinitionIndex | Should -BeNullOrEmpty + } finally { + $env:CIPPRootPath = $script:FakeRoot + } + } +} + +Describe 'Compare-CIPPIntuneObject -CompareType Catalog' { + BeforeEach { Set-FakeCollection } + + It 'reports no drift when the policies match' { + $Policy = New-CatalogPolicy @((New-CatalogSetting -DefinitionId 'setting_toggle' -Type 'choice' -Value 'setting_toggle_1')) + Compare-CIPPIntuneObject -ReferenceObject $Policy -DifferenceObject $Policy -CompareType 'Catalog' | Should -BeNullOrEmpty + } + + It 'renders choice values as their friendly option labels on both sides' { + $Ref = New-CatalogPolicy @((New-CatalogSetting -DefinitionId 'setting_toggle' -Type 'choice' -Value 'setting_toggle_1')) + $Diff = New-CatalogPolicy @((New-CatalogSetting -DefinitionId 'setting_toggle' -Type 'choice' -Value 'setting_toggle_0')) + + $Result = @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Diff -CompareType 'Catalog') + $Result.Count | Should -Be 1 + $Result[0].Property | Should -Be 'Toggle Setting' + $Result[0].ExpectedValue | Should -Be 'On' + $Result[0].ReceivedValue | Should -Be 'Off' + } + + It 'resolves every member of a choice collection and sorts the rendered list' { + $Ref = New-CatalogPolicy @((New-CatalogSetting -DefinitionId 'setting_multi' -Type 'choiceCollection' -Value @('setting_multi_b', 'setting_multi_a'))) + $Diff = New-CatalogPolicy @((New-CatalogSetting -DefinitionId 'setting_multi' -Type 'choiceCollection' -Value @('setting_multi_a'))) + + $Result = @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Diff -CompareType 'Catalog') + $Result[0].ExpectedValue | Should -Be 'Alpha, Bravo' + $Result[0].ReceivedValue | Should -Be 'Alpha' + } + + It 'keeps the raw value when it matches no option' { + $Ref = New-CatalogPolicy @((New-CatalogSetting -DefinitionId 'setting_toggle' -Type 'choice' -Value 'value_with_no_option')) + $Diff = New-CatalogPolicy @((New-CatalogSetting -DefinitionId 'setting_toggle' -Type 'choice' -Value 'setting_toggle_1')) + + $Result = @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Diff -CompareType 'Catalog') + $Result[0].ExpectedValue | Should -Be 'value_with_no_option' + $Result[0].ReceivedValue | Should -Be 'On' + } + + It 'falls back to the setting id when the definition is not in the collection' { + $Ref = New-CatalogPolicy @((New-CatalogSetting -DefinitionId 'setting_not_shipped' -Type 'simple' -Value '1')) + $Diff = New-CatalogPolicy @((New-CatalogSetting -DefinitionId 'setting_not_shipped' -Type 'simple' -Value '2')) + + $Result = @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Diff -CompareType 'Catalog') + $Result[0].Property | Should -Be 'setting_not_shipped' + $Result[0].ExpectedValue | Should -Be '1' + $Result[0].ReceivedValue | Should -Be '2' + } + + It 'flags a setting present on only one side' { + $Ref = New-CatalogPolicy @( + (New-CatalogSetting -DefinitionId 'setting_toggle' -Type 'choice' -Value 'setting_toggle_1'), + (New-CatalogSetting -DefinitionId 'setting_plain' -Type 'simple' -Value '300') + ) + $Diff = New-CatalogPolicy @((New-CatalogSetting -DefinitionId 'setting_toggle' -Type 'choice' -Value 'setting_toggle_1')) + + $Result = @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Diff -CompareType 'Catalog') + $Result.Count | Should -Be 1 + $Result[0].Property | Should -Be 'Plain Setting' + $Result[0].ExpectedValue | Should -Be '300' + $Result[0].ReceivedValue | Should -BeNullOrEmpty + } + + It 'does not reparse the collection once it is cached' { + $Policy = New-CatalogPolicy @((New-CatalogSetting -DefinitionId 'setting_toggle' -Type 'choice' -Value 'setting_toggle_1')) + Compare-CIPPIntuneObject -ReferenceObject $Policy -DifferenceObject $Policy -CompareType 'Catalog' | Out-Null + + # Deleting the file mid-run proves the second comparison never went back to disk. + Remove-Item -LiteralPath $script:CollectionPath -Force + try { + $Diff = New-CatalogPolicy @((New-CatalogSetting -DefinitionId 'setting_toggle' -Type 'choice' -Value 'setting_toggle_0')) + $Result = @(Compare-CIPPIntuneObject -ReferenceObject $Policy -DifferenceObject $Diff -CompareType 'Catalog') + $Result[0].ExpectedValue | Should -Be 'On' + $Result[0].ReceivedValue | Should -Be 'Off' + } finally { + Set-FakeCollection + } + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardConditionalAccessTemplate.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardConditionalAccessTemplate.Tests.ps1 new file mode 100644 index 0000000000000..3adb9856889fc --- /dev/null +++ b/Tests/Standards/Invoke-CIPPStandardConditionalAccessTemplate.Tests.ps1 @@ -0,0 +1,142 @@ +# Pester tests for Invoke-CIPPStandardConditionalAccessTemplate +# Covers how the standard resolves its template row out of the templates table: +# by RowKey, by the GUID column when the two have diverged, and the not-found path. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + # Resolve by name under Modules/ so the test survives the function moving between modules. + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardConditionalAccessTemplate.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardConditionalAccessTemplate.ps1 under Modules/' } + + # Stubs mirror the real signatures and are advanced functions on purpose: strict + # parameter binding makes signature drift in the standard fail loudly here instead + # of silently landing in $args and leaving the captured value $null. + function Get-CIPPDbItem { [CmdletBinding()] param($TenantFilter, $Type, [switch]$CountsOnly) } + function Set-CIPPDBCacheConditionalAccessPolicies { [CmdletBinding()] param($TenantFilter) } + function New-CIPPDbRequest { [CmdletBinding()] param($TenantFilter, $Type) } + function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) } + function Get-CippTable { [CmdletBinding()] param($tablename) } + function Get-CippAzDataTableEntity { [CmdletBinding()] param($Table, $Filter) } + function ConvertTo-CIPPODataFilterValue { [CmdletBinding()] param([AllowEmptyString()][string]$Value, $Type) $Value } + function New-CIPPCAPolicy { [CmdletBinding()] param($replacePattern, $TenantFilter, $state, $RawJSON, $Overwrite, $APIName, $Headers, $DisableSD, $CreateGroups, $PreloadedCAPolicies, $PreloadedLocations, $PreloadedSecurityDefaults) } + function New-CIPPCATemplate { [CmdletBinding()] param($TenantFilter, $JSON, $preloadedLocations) } + function Compare-CIPPIntuneObject { [CmdletBinding()] param($ReferenceObject, $DifferenceObject, $CompareType) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $Tenant2, $message, $sev, $headers) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $FieldValue, $CurrentValue, $ExpectedValue, $Tenant, [bool]$LicenseAvailable = $true) } + function Get-NormalizedError { [CmdletBinding()] param($Message) $Message } + + . $StandardPath + + # Script scope: Pester 5 evaluates the Describe body at discovery, so plain variables declared + # there are not in scope inside It blocks or mocks at run time. + $script:Tenant = 'contoso.onmicrosoft.com' + # Shape mirrors a template produced by New-CIPPCATemplate from a live policy: the embedded + # `id` is the source tenant's policy id and is never the key the standard looks up. + $script:TemplateJson = '{"id":"a3b48aca-e723-469d-81f1-86e550dd7d48","displayName":"CA004: Require login from inside Netherlands","state":"disabled","conditions":{"users":{"includeUsers":["All"]}},"grantControls":{"operator":"OR","builtInControls":["block"]}}' +} + +Describe 'Invoke-CIPPStandardConditionalAccessTemplate template resolution' { + BeforeEach { + $script:logs = @() + $script:compareFields = @() + $script:deployCalls = 0 + + Mock -CommandName Get-CIPPDbItem -MockWith { [pscustomobject]@{ Timestamp = (Get-Date); DataCount = 5 } } + Mock -CommandName Set-CIPPDBCacheConditionalAccessPolicies -MockWith { } + Mock -CommandName New-CIPPDbRequest -MockWith { @() } + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + Mock -CommandName Get-CippTable -MockWith { @{ Table = 'templates' } } + Mock -CommandName New-CIPPCAPolicy -MockWith { $script:deployCalls++ } + Mock -CommandName Write-LogMessage -MockWith { + param($API, $tenant, $message, $sev) + $script:logs += @{ Message = $message; Sev = $sev } + } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { + param($FieldName, $FieldValue, $CurrentValue, $ExpectedValue, $Tenant, $LicenseAvailable) + $script:compareFields += @{ Field = $FieldName; Current = $CurrentValue } + } + } + + It 'resolves the template when RowKey matches the stored TemplateList value' { + Mock -CommandName Get-CippAzDataTableEntity -MockWith { + param($Table, $Filter) + if ($Filter -match "RowKey eq 'template-guid'") { + @([pscustomobject]@{ RowKey = 'template-guid'; GUID = 'template-guid'; JSON = $script:TemplateJson }) + } + } + + $Settings = [pscustomobject]@{ + report = $true + TemplateList = [pscustomobject]@{ label = 'CA004: Require login from inside Netherlands'; value = 'template-guid' } + } + Invoke-CIPPStandardConditionalAccessTemplate -Tenant $script:Tenant -Settings $Settings + + $script:logs.Message | Should -Not -Contain "Conditional Access template 'CA004: Require login from inside Netherlands' (template-guid) could not be loaded from the template store - skipping." + $script:compareFields[0].Current.Differences | Should -Be 'Policy is missing from this tenant.' + } + + It 'resolves the template by the GUID column when it has diverged from RowKey' { + # One query matches either key, so a row whose GUID and RowKey disagree still resolves. + Mock -CommandName Get-CippAzDataTableEntity -MockWith { + param($Table, $Filter) + if ($Filter -match "GUID eq 'template-guid'") { + @([pscustomobject]@{ RowKey = 'a-different-rowkey'; GUID = 'template-guid'; JSON = $script:TemplateJson }) + } + } + + $Settings = [pscustomobject]@{ + report = $true + TemplateList = [pscustomobject]@{ label = 'CA004: Require login from inside Netherlands'; value = 'template-guid' } + } + Invoke-CIPPStandardConditionalAccessTemplate -Tenant $script:Tenant -Settings $Settings + + ($script:logs | Where-Object { $_.Message -match 'could not be loaded from the template store' }) | Should -BeNullOrEmpty + $script:compareFields[0].Current.Differences | Should -Be 'Policy is missing from this tenant.' + } + + It 'resolves the template in a single query' { + # Both keys are matched in one filter rather than a lookup and a fallback. + $script:queries = @() + Mock -CommandName Get-CippAzDataTableEntity -MockWith { + param($Table, $Filter) + $script:queries += $Filter + @([pscustomobject]@{ RowKey = 'template-guid'; GUID = 'template-guid'; JSON = $script:TemplateJson }) + } + + $Settings = [pscustomobject]@{ + report = $true + TemplateList = [pscustomobject]@{ label = 'CA004: Require login from inside Netherlands'; value = 'template-guid' } + } + Invoke-CIPPStandardConditionalAccessTemplate -Tenant $script:Tenant -Settings $Settings + + @($script:queries | Where-Object { $_ -match 'CATemplate' }).Count | Should -Be 1 + $script:queries[0] | Should -Match "RowKey eq 'template-guid' or GUID eq 'template-guid'" + } + + It 'reports a clear error when neither key resolves a row' { + Mock -CommandName Get-CippAzDataTableEntity -MockWith { } + + $Settings = [pscustomobject]@{ + report = $true + TemplateList = [pscustomobject]@{ label = 'CA004: Require login from inside Netherlands'; value = 'missing-guid' } + } + Invoke-CIPPStandardConditionalAccessTemplate -Tenant $script:Tenant -Settings $Settings + + ($script:logs | Where-Object { $_.Message -match 'could not be loaded from the template store' -and $_.Sev -eq 'Error' }) | Should -Not -BeNullOrEmpty + $script:compareFields[0].Current.Differences | Should -Match 'could not be loaded from the template store' + } + + It 'does not attempt a deployment with a null template body' { + Mock -CommandName Get-CippAzDataTableEntity -MockWith { } + + $Settings = [pscustomobject]@{ + remediate = $true + TemplateList = [pscustomobject]@{ label = 'CA004: Require login from inside Netherlands'; value = 'missing-guid' } + } + Invoke-CIPPStandardConditionalAccessTemplate -Tenant $script:Tenant -Settings $Settings + + $script:deployCalls | Should -Be 0 + ($script:logs | Where-Object { $_.Message -match 'Failed to create or update conditional access rule' }) | Should -BeNullOrEmpty + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.Tests.ps1 new file mode 100644 index 0000000000000..86a4ada6a26dd --- /dev/null +++ b/Tests/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.Tests.ps1 @@ -0,0 +1,143 @@ +# Pester tests for Invoke-CIPPStandardDisableExchangeOnlinePowerShell +# +# Issue #237: a fully successful run reported "0 out of N" with no per-user errors. +# Set-User returns no body on success, and New-ExoBulkRequest only synthesises a +# { Success = $true } record when the request carried an OperationGuid - so without one +# every success was invisible. The mock below reproduces that contract exactly. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1 under Modules/' } + + function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) } + function New-GraphGetRequest { [CmdletBinding()] param($uri, $tenantid, $AsApp, $NoAuthCheck, $skipTokenCache) } + function New-GraphBulkRequest { [CmdletBinding()] param($tenantid, $Requests, $Version, $AsApp, $NoAuthCheck) } + function New-CIPPDbRequest { [CmdletBinding()] param($TenantFilter, $Type) } + function New-ExoBulkRequest { [CmdletBinding()] param($tenantid, $cmdletArray, [switch]$ReturnWithCommand, [switch]$useSystemMailbox, $Anchor) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $Tenant2, $message, $sev, $headers, $LogData) } + function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $CurrentValue, $ExpectedValue, $TenantFilter) } + function Add-CIPPBPAField { [CmdletBinding()] param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Get-NormalizedError { [CmdletBinding()] param($Message) $Message } + function Get-CippException { [CmdletBinding()] param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $StandardPath + + $script:Tenant = 'contoso.onmicrosoft.com' + + # Mirrors New-ExoBulkRequest's non-ReturnWithCommand contract: a setter emits nothing on + # success unless the caller supplied an OperationGuid, in which case a synthetic success + # record is produced. Errors always come back. + function script:Invoke-FakeExoBulk { + param($cmdletArray, [string[]]$FailFor = @()) + foreach ($Cmd in @($cmdletArray)) { + $Identity = $Cmd.CmdletInput.Parameters.Identity + $Guid = $Cmd.OperationGuid + if ($FailFor -contains $Identity -or $FailFor -contains $Guid) { + $Err = [pscustomobject]@{ error = "Could not set user $Identity"; target = $Identity } + if ($Guid) { $Err | Add-Member -NotePropertyName OperationGuid -NotePropertyValue $Guid -Force } + $Err + } elseif ($Guid) { + [pscustomobject]@{ Success = $true; OperationGuid = $Guid } + } + # no OperationGuid + success => nothing emitted, exactly like the real helper + } + } +} + +Describe 'Invoke-CIPPStandardDisableExchangeOnlinePowerShell remediation' { + BeforeEach { + $script:logs = [System.Collections.Generic.List[object]]::new() + $script:sentArray = $null + + $script:Mailboxes = @( + [pscustomobject]@{ UPN = 'ann@contoso.com'; Guid = '11111111-1111-1111-1111-111111111111'; RemotePowerShellEnabled = $true } + [pscustomobject]@{ UPN = 'ben@contoso.com'; Guid = '22222222-2222-2222-2222-222222222222'; RemotePowerShellEnabled = $true } + [pscustomobject]@{ UPN = 'cat@contoso.com'; Guid = $null; RemotePowerShellEnabled = $true } + ) + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + Mock -CommandName New-GraphGetRequest -MockWith { @() } + Mock -CommandName New-GraphBulkRequest -MockWith { @() } + Mock -CommandName New-CIPPDbRequest -MockWith { $script:Mailboxes } + Mock -CommandName Write-StandardsAlert -MockWith { } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { } + Mock -CommandName Add-CIPPBPAField -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { + param($API, $tenant, $message, $sev, $LogData) + $script:logs.Add(@{ Message = $message; Sev = $sev }) + } + Mock -CommandName New-ExoBulkRequest -MockWith { + param($tenantid, $cmdletArray) + $script:sentArray = @($cmdletArray) + script:Invoke-FakeExoBulk -cmdletArray $cmdletArray + } + } + + It 'counts every user when all of them succeed' { + Invoke-CIPPStandardDisableExchangeOnlinePowerShell -Tenant $script:Tenant -Settings @{ remediate = $true } + + $Summary = @($script:logs | Where-Object { $_.Message -match 'out of' }) + $Summary.Count | Should -Be 1 + $Summary[0].Message | Should -Match 'for 3 out of 3 users' + } + + It 'tags every request with an OperationGuid so successes are visible' { + Invoke-CIPPStandardDisableExchangeOnlinePowerShell -Tenant $script:Tenant -Settings @{ remediate = $true } + + @($script:sentArray).Count | Should -Be 3 + foreach ($Cmd in $script:sentArray) { + $Cmd.OperationGuid | Should -Not -BeNullOrEmpty + } + ($script:sentArray.OperationGuid | Sort-Object) | Should -Be @('ann@contoso.com', 'ben@contoso.com', 'cat@contoso.com') + } + + It 'logs no error and no short-batch warning on a clean run' { + Invoke-CIPPStandardDisableExchangeOnlinePowerShell -Tenant $script:Tenant -Settings @{ remediate = $true } + + @($script:logs | Where-Object { $_.Sev -eq 'Error' }).Count | Should -Be 0 + @($script:logs | Where-Object { $_.Message -match 'neither confirmed nor reported' }).Count | Should -Be 0 + } + + It 'reports partial success and names the failed user' { + Mock -CommandName New-ExoBulkRequest -MockWith { + param($tenantid, $cmdletArray) + $script:sentArray = @($cmdletArray) + script:Invoke-FakeExoBulk -cmdletArray $cmdletArray -FailFor 'ben@contoso.com' + } + + Invoke-CIPPStandardDisableExchangeOnlinePowerShell -Tenant $script:Tenant -Settings @{ remediate = $true } + + (@($script:logs | Where-Object { $_.Message -match 'out of' })[0]).Message | Should -Match 'for 2 out of 3 users' + $Errors = @($script:logs | Where-Object { $_.Sev -eq 'Error' }) + $Errors.Count | Should -Be 1 + $Errors[0].Message | Should -Match 'ben@contoso.com' + } + + It 'falls back to the UPN as Identity when the cached mailbox has no Guid' { + Invoke-CIPPStandardDisableExchangeOnlinePowerShell -Tenant $script:Tenant -Settings @{ remediate = $true } + + $Cat = $script:sentArray | Where-Object { $_.OperationGuid -eq 'cat@contoso.com' } + $Cat.CmdletInput.Parameters.Identity | Should -BeExactly 'cat@contoso.com' + # An empty-string Guid must not be sent as the Identity either. + foreach ($Cmd in $script:sentArray) { + $Cmd.CmdletInput.Parameters.Identity | Should -Not -BeNullOrEmpty + } + } + + It 'warns when Exchange returns fewer results than users requested' { + Mock -CommandName New-ExoBulkRequest -MockWith { + param($tenantid, $cmdletArray) + $script:sentArray = @($cmdletArray) + # Only the first user comes back at all. + [pscustomobject]@{ Success = $true; OperationGuid = 'ann@contoso.com' } + } + + Invoke-CIPPStandardDisableExchangeOnlinePowerShell -Tenant $script:Tenant -Settings @{ remediate = $true } + + @($script:logs | Where-Object { $_.Message -match 'neither confirmed nor reported' }).Count | + Should -Be 1 -Because 'silently dropping users is what made #237 undiagnosable' + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardDisableM365GroupUsers.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardDisableM365GroupUsers.Tests.ps1 new file mode 100644 index 0000000000000..dd076df7b64b7 --- /dev/null +++ b/Tests/Standards/Invoke-CIPPStandardDisableM365GroupUsers.Tests.ps1 @@ -0,0 +1,251 @@ +# Pester tests for Invoke-CIPPStandardDisableM365GroupUsers +# +# Covers the remediation paths that produced issue #205: the tenant with no Group.Unified +# directory setting (where /beta/settings is eventually consistent and a read-back returns +# nothing), a values collection missing the entry being set, and a partial read that must +# not be patched over the top of. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + # Resolve by name under Modules/ so the test survives the function moving between modules. + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardDisableM365GroupUsers.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardDisableM365GroupUsers.ps1 under Modules/' } + + # Stubs mirror the real signatures and are advanced functions on purpose: strict parameter + # binding makes signature drift in the standard fail loudly here instead of silently + # landing in $args. + function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) } + function New-GraphGetRequest { [CmdletBinding()] param($Uri, $tenantid, $AsApp, $NoAuthCheck, $skipTokenCache, $ComplexFilter, $CountOnly) } + function New-GraphPostRequest { [CmdletBinding()] param($uri, $tenantid, $body, $type, $scope, $AsApp, $NoAuthCheck, $skipTokenCache, $AddedHeaders, $contentType, $IgnoreErrors, $returnHeaders, $maxRetries) } + function New-CIPPGroup { [CmdletBinding()] param($GroupObject, $TenantFilter, $APIName, $Headers) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $Tenant2, $message, $sev, $headers, $LogData) } + function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $CurrentValue, $ExpectedValue, $TenantFilter) } + function Add-CIPPBPAField { [CmdletBinding()] param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Get-NormalizedError { [CmdletBinding()] param($Message) $Message } + function Get-CippException { [CmdletBinding()] param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $StandardPath + + # Script scope: Pester 5 evaluates the Describe body at discovery, so plain variables + # declared there are not in scope inside It blocks or mocks at run time. + $script:Tenant = 'contoso.onmicrosoft.com' + $script:TemplateId = '62375ab9-6b52-47ed-826b-58e47e0e304b' + + # The 15 names Microsoft ships on the Group.Unified template, verified against live Graph. + $script:TemplateNames = @( + 'NewUnifiedGroupWritebackDefault', 'EnableMIPLabels', 'CustomBlockedWordsList', + 'EnableMSStandardBlockedWords', 'ClassificationDescriptions', 'DefaultClassification', + 'PrefixSuffixNamingRequirement', 'AllowGuestsToBeGroupOwner', 'AllowGuestsToAccessGroups', + 'GuestUsageGuidelinesUrl', 'GroupCreationAllowedGroupId', 'AllowToAddGuests', + 'UsageGuidelinesUrl', 'ClassificationList', 'EnableGroupCreation' + ) + + function script:New-TemplateResponse { + $Values = foreach ($Name in $script:TemplateNames) { + $Default = switch ($Name) { + 'NewUnifiedGroupWritebackDefault' { 'true' } + 'AllowGuestsToAccessGroups' { 'true' } + 'AllowToAddGuests' { 'true' } + 'EnableGroupCreation' { 'true' } + default { '' } + } + [pscustomobject]@{ name = $Name; type = 'System.String'; defaultValue = $Default } + } + [pscustomobject]@{ id = $script:TemplateId; displayName = 'Group.Unified'; values = @($Values) } + } + + # An existing tenant setting. Pass -Omit to drop entries, mirroring a setting created + # from an older template revision. + function script:New-SettingResponse { + param([string[]]$Omit = @(), [string]$EnableGroupCreation = 'true', [string]$Id = '093005de-06c3-4f4f-ac7b-0c161abaab09') + $Values = foreach ($Name in $script:TemplateNames) { + if ($Omit -contains $Name) { continue } + $Value = switch ($Name) { + 'EnableGroupCreation' { $EnableGroupCreation } + 'AllowGuestsToAccessGroups' { 'true' } + 'AllowToAddGuests' { 'true' } + 'NewUnifiedGroupWritebackDefault' { 'true' } + default { '' } + } + [pscustomobject]@{ name = $Name; value = $Value } + } + [pscustomobject]@{ id = $Id; displayName = 'Group.Unified'; templateId = $script:TemplateId; values = @($Values) } + } +} + +Describe 'Invoke-CIPPStandardDisableM365GroupUsers remediation' { + BeforeEach { + $script:logs = [System.Collections.Generic.List[object]]::new() + $script:posts = [System.Collections.Generic.List[object]]::new() + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + Mock -CommandName Write-StandardsAlert -MockWith { } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { } + Mock -CommandName Add-CIPPBPAField -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { + param($API, $tenant, $message, $sev, $LogData) + $script:logs.Add(@{ Message = $message; Sev = $sev }) + } + Mock -CommandName New-GraphPostRequest -MockWith { + param($uri, $tenantid, $body, $type, $AsApp, $contentType) + $script:posts.Add(@{ Uri = $uri; Type = "$type"; Body = $body }) + # Mirror live Graph: the create POST returns the created object (displayName empty). + if ("$type" -eq 'POST') { + $Parsed = $body | ConvertFrom-Json + return [pscustomobject]@{ + id = 'd7ca702d-3b44-41d5-8b85-52d8b2355ac5' + displayName = '' + templateId = $Parsed.templateId + values = @($Parsed.values) + } + } + return $null + } + } + + Context 'tenant has no Group.Unified setting (issue #205)' { + BeforeEach { + # Live behaviour: /beta/settings returns nothing, and still returns nothing when + # read straight back after the create. + Mock -CommandName New-GraphGetRequest -MockWith { + param($Uri, $tenantid) + if ($Uri -like '*directorySettingTemplates*') { return script:New-TemplateResponse } + return @() + } + } + + It 'does not throw the reported "property value cannot be found" error' { + { Invoke-CIPPStandardDisableM365GroupUsers -Tenant $script:Tenant -Settings @{ remediate = $true } } | + Should -Not -Throw + + $Failures = $script:logs | Where-Object { $_.Sev -eq 'Error' } + $Failures | Should -BeNullOrEmpty -Because 'the create path should succeed, not log a failure' + } + + It 'creates the setting with a single well-formed POST and no PATCH' { + Invoke-CIPPStandardDisableM365GroupUsers -Tenant $script:Tenant -Settings @{ remediate = $true } + + $Creates = @($script:posts | Where-Object { $_.Type -eq 'POST' }) + $Creates.Count | Should -Be 1 + # The old code built ".../settings/$($string.id)" which collapsed to a trailing slash. + $Creates[0].Uri | Should -BeExactly 'https://graph.microsoft.com/beta/settings' + $Creates[0].Uri | Should -Not -Match '/settings/$' + + @($script:posts | Where-Object { $_.Type -match 'patch' }).Count | + Should -Be 0 -Because 'the setting is created already correct, so no follow-up patch is needed' + } + + It 'sends templateId and the desired values, without read-only properties' { + Invoke-CIPPStandardDisableM365GroupUsers -Tenant $script:Tenant -Settings @{ remediate = $true } + + $Body = (@($script:posts | Where-Object { $_.Type -eq 'POST' })[0]).Body | ConvertFrom-Json + $Body.templateId | Should -BeExactly $script:TemplateId + @($Body.values).Count | Should -Be 15 + ($Body.values | Where-Object name -EQ 'EnableGroupCreation').value | Should -BeExactly 'false' + $Body.PSObject.Properties.Name | Should -Not -Contain 'id' + $Body.PSObject.Properties.Name | Should -Not -Contain 'displayName' + } + + It 'falls back to the built-in template when the template endpoint fails' { + Mock -CommandName New-GraphGetRequest -MockWith { + param($Uri, $tenantid) + if ($Uri -like '*directorySettingTemplates*') { throw 'Graph is having a moment' } + return @() + } + + { Invoke-CIPPStandardDisableM365GroupUsers -Tenant $script:Tenant -Settings @{ remediate = $true } } | + Should -Not -Throw + + $Body = (@($script:posts | Where-Object { $_.Type -eq 'POST' })[0]).Body | ConvertFrom-Json + @($Body.values).Count | Should -Be 15 + ($Body.values | Where-Object name -EQ 'EnableGroupCreation').value | Should -BeExactly 'false' + } + } + + Context 'existing setting' { + BeforeEach { + Mock -CommandName New-GraphGetRequest -MockWith { + param($Uri, $tenantid) + if ($Uri -like '*directorySettingTemplates*') { return script:New-TemplateResponse } + return @(script:New-SettingResponse) + } + } + + It 'patches a body that is valid JSON with values as an array' { + Invoke-CIPPStandardDisableM365GroupUsers -Tenant $script:Tenant -Settings @{ remediate = $true } + + $Patch = @($script:posts | Where-Object { $_.Type -match 'patch' }) + $Patch.Count | Should -Be 1 + # The old body was "{values : [...]}" - an unquoted key, so not strict JSON. + $Patch[0].Body | Should -Not -Match '^\{values\s*:' + $Parsed = $Patch[0].Body | ConvertFrom-Json + $Parsed.values | Should -BeOfType [System.Object] + @($Parsed.values).Count | Should -Be 15 + ($Parsed.values | Where-Object name -EQ 'EnableGroupCreation').value | Should -BeExactly 'false' + } + + It 'leaves unrelated values untouched' { + Invoke-CIPPStandardDisableM365GroupUsers -Tenant $script:Tenant -Settings @{ remediate = $true } + + $Parsed = (@($script:posts | Where-Object { $_.Type -match 'patch' })[0]).Body | ConvertFrom-Json + ($Parsed.values | Where-Object name -EQ 'AllowGuestsToAccessGroups').value | Should -BeExactly 'true' + ($Parsed.values | Where-Object name -EQ 'AllowToAddGuests').value | Should -BeExactly 'true' + } + + It 'adds EnableGroupCreation when the tenant setting does not have it' { + Mock -CommandName New-GraphGetRequest -MockWith { + param($Uri, $tenantid) + if ($Uri -like '*directorySettingTemplates*') { return script:New-TemplateResponse } + return @(script:New-SettingResponse -Omit 'EnableGroupCreation') + } + + { Invoke-CIPPStandardDisableM365GroupUsers -Tenant $script:Tenant -Settings @{ remediate = $true } } | + Should -Not -Throw + + $Parsed = (@($script:posts | Where-Object { $_.Type -match 'patch' })[0]).Body | ConvertFrom-Json + ($Parsed.values | Where-Object name -EQ 'EnableGroupCreation').value | Should -BeExactly 'false' + } + + It 'sets GroupCreationAllowedGroupId when an allowed group is configured' { + Mock -CommandName New-GraphGetRequest -MockWith { + param($Uri, $tenantid) + if ($Uri -like '*directorySettingTemplates*') { return script:New-TemplateResponse } + if ($Uri -like '*/groups?*') { return @([pscustomobject]@{ id = 'aaaabbbb-cccc-dddd-eeee-ffff00001111'; displayName = 'Group Creators' }) } + return @(script:New-SettingResponse) + } + + Invoke-CIPPStandardDisableM365GroupUsers -Tenant $script:Tenant -Settings @{ remediate = $true; AllowedGroupName = 'Group Creators' } + + $Parsed = (@($script:posts | Where-Object { $_.Type -match 'patch' })[0]).Body | ConvertFrom-Json + ($Parsed.values | Where-Object name -EQ 'GroupCreationAllowedGroupId').value | + Should -BeExactly 'aaaabbbb-cccc-dddd-eeee-ffff00001111' + @($Parsed.values | Where-Object name -EQ 'GroupCreationAllowedGroupId').Count | + Should -Be 1 -Because 'the entry must be replaced, not duplicated' + } + } + + Context 'partial read' { + It 'refuses to patch when the setting comes back with no values' { + Mock -CommandName New-GraphGetRequest -MockWith { + param($Uri, $tenantid) + if ($Uri -like '*directorySettingTemplates*') { return script:New-TemplateResponse } + return @([pscustomobject]@{ id = '093005de-06c3-4f4f-ac7b-0c161abaab09'; displayName = 'Group.Unified'; values = @() }) + } + + { Invoke-CIPPStandardDisableM365GroupUsers -Tenant $script:Tenant -Settings @{ remediate = $true } } | + Should -Not -Throw + + @($script:posts).Count | + Should -Be 0 -Because 'patching a truncated values collection would wipe the other group settings' + + # The old code also issued no PATCH here, but only because it crashed on the null + # assignment first. Assert it declined deliberately, with a diagnosable message. + $Errors = @($script:logs | Where-Object { $_.Sev -eq 'Error' }) + $Errors.Count | Should -BeGreaterThan 0 + $Errors[0].Message | Should -Match 'came back incomplete' + $Errors[0].Message | Should -Not -Match "property 'value' cannot be found" + } + } +} diff --git a/Tests/Static/SteppablePipelineScope.Tests.ps1 b/Tests/Static/SteppablePipelineScope.Tests.ps1 new file mode 100644 index 0000000000000..60f03321fc143 --- /dev/null +++ b/Tests/Static/SteppablePipelineScope.Tests.ps1 @@ -0,0 +1,198 @@ +# Source invariant: where a steppable pipeline may be created. +# +# ScriptBlock.GetSteppablePipeline() captures whichever scope is live at the moment it is called, +# and the writer's begin/process/end blocks are later run against that captured scope. Creating +# one inside the scriptblock of a pipeline stage therefore captures the *upstream command's* +# scope, not the enclosing function's. Begin() and Process() still resolve, because they run +# while the upstream command is on the stack - but by the time End() is called the upstream +# pipeline has completed and its scope is gone, so every command the end block calls dies with +# "The term '...' is not recognized as a name of a cmdlet, function, script file, or executable +# program". +# +# That is a genuinely nasty failure. The DBCache collectors hit it: rows already flushed in +# 100-row batches landed, but the count row and the orphan cleanup never ran, so the cache went +# quietly and permanently short while the logbook showed a missing-cmdlet error that had nothing +# to do with the real problem. Nothing about the calling code looks wrong, and the usual Pester +# style of dot-sourcing a function and stubbing its dependencies into the test session cannot +# reproduce it, because that collapses everything into one session state. +# +# Measured behaviour of the four shapes, with the writer and the collector in separate modules: +# +# | ForEach-Object { ...GetSteppablePipeline()... } BREAKS +# | ForEach-Object { ...GetSteppablePipeline()... } works today +# $Variable / 1..5 | ForEach-Object { ...GetSteppablePipeline()... } safe +# foreach ($x in $Array) { ...GetSteppablePipeline()... } safe +# +# This rule flags any command upstream, not only a cross-module one. That is deliberately one +# notch stricter than the measured hazard: whether a callee lives in the same module is not +# visible at the call site and changes the day a helper moves between CIPPCore and its callers, +# and the fix - hoist the writer above the pipeline and gate End() on whether any rows arrived - +# costs nothing. Pipelines fed by a variable or a literal are left alone, since no command scope +# is torn down there. + +BeforeAll { + $BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $script:ModuleRoot = Join-Path $BackendRoot 'Modules' + if (-not (Test-Path $script:ModuleRoot)) { throw "Module root not found at $script:ModuleRoot" } + + function Get-SteppableScopeViolation { + <# + Returns one record per GetSteppablePipeline() call that is lexically inside a + scriptblock passed to a pipeline stage whose upstream element is a command. + #> + param( + [Parameter(Mandatory)][System.Management.Automation.Language.Ast]$Ast, + [string]$Path = '' + ) + + $Calls = $Ast.FindAll({ + param($Node) + $Node -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and + $Node.Member -is [System.Management.Automation.Language.StringConstantExpressionAst] -and + $Node.Member.Value -eq 'GetSteppablePipeline' + }, $true) + + foreach ($Call in $Calls) { + $Node = $Call + while ($null -ne $Node.Parent) { + $Parent = $Node.Parent + + # A scriptblock handed to a command: ForEach-Object { }, Where-Object { }, etc. + if ($Node -is [System.Management.Automation.Language.ScriptBlockExpressionAst] -and + $Parent -is [System.Management.Automation.Language.CommandAst] -and + $Parent.Parent -is [System.Management.Automation.Language.PipelineAst]) { + + $Elements = $Parent.Parent.PipelineElements + $Position = $Elements.IndexOf($Parent) + + # Only a stage with something upstream of it, and only when that upstream is a + # command whose scope will be torn down when the pipeline completes. + if ($Position -gt 0 -and $Elements[0] -is [System.Management.Automation.Language.CommandAst]) { + [pscustomobject]@{ + Path = $Path + Line = $Call.Extent.StartLineNumber + Upstream = $Elements[0].GetCommandName() + Stage = $Parent.GetCommandName() + } + break + } + } + + $Node = $Parent + } + } + } + + function Get-ViolationFromSource { + param([Parameter(Mandatory)][string]$Source) + $Ast = [System.Management.Automation.Language.Parser]::ParseInput($Source, [ref]$null, [ref]$null) + @(Get-SteppableScopeViolation -Ast $Ast) + } + + # Text prefilter first - parsing every .ps1 under Modules/ to find a handful of call sites + # would dominate the suite's runtime. + $script:Candidates = @( + Get-ChildItem -Path $script:ModuleRoot -Filter '*.ps1' -Recurse -File | + Select-String -Pattern 'GetSteppablePipeline' -List | + Select-Object -ExpandProperty Path + ) +} + +Describe 'Steppable pipeline scope capture' { + + Context 'the detector itself' { + # A lint rule that silently stops matching is worse than no rule, so the detector is + # pinned against the shapes it is meant to separate. + + It 'flags a writer created inside a stage fed by a command' { + $Violations = Get-ViolationFromSource -Source @' +function Set-Thing { + $Writer = $null + New-GraphGetRequest -uri 'x' -Stream | ForEach-Object { + if ($null -eq $Writer) { + $Writer = { Add-CIPPDbItem -Type 'T' -AddCount }.GetSteppablePipeline() + $Writer.Begin($true) + } + $Writer.Process($_) + } + if ($Writer) { $Writer.End() } +} +'@ + $Violations.Count | Should -Be 1 + $Violations[0].Upstream | Should -Be 'New-GraphGetRequest' + $Violations[0].Stage | Should -Be 'ForEach-Object' + } + + It 'accepts a writer created before the pipeline starts' { + Get-ViolationFromSource -Source @' +function Set-Thing { + $Count = 0 + $Writer = { Add-CIPPDbItem -Type 'T' -AddCount }.GetSteppablePipeline() + $Writer.Begin($true) + try { + New-GraphGetRequest -uri 'x' -Stream | ForEach-Object { $Count++; $Writer.Process($_) } + if ($Count -gt 0) { $Writer.End() } + } finally { $Writer.Dispose() } +} +'@ | Should -BeNullOrEmpty + } + + It 'accepts a writer created inside a foreach statement' { + Get-ViolationFromSource -Source @' +function Set-Thing { + $Writer = $null + foreach ($Row in $Rows) { + if ($null -eq $Writer) { + $Writer = { Add-CIPPDbItem -Type 'T' -AddCount }.GetSteppablePipeline() + $Writer.Begin($true) + } + $Writer.Process($Row) + } + if ($Writer) { $Writer.End() } +} +'@ | Should -BeNullOrEmpty + } + + It 'accepts a stage fed by a variable rather than a command' { + Get-ViolationFromSource -Source @' +function Set-Thing { + $Writer = $null + $Rows | ForEach-Object { + if ($null -eq $Writer) { + $Writer = { Add-CIPPDbItem -Type 'T' -AddCount }.GetSteppablePipeline() + $Writer.Begin($true) + } + $Writer.Process($_) + } + if ($Writer) { $Writer.End() } +} +'@ | Should -BeNullOrEmpty + } + } + + Context 'module sources' { + + It 'has steppable pipeline call sites to inspect' { + # Guards against the prefilter quietly matching nothing after a refactor. + $script:Candidates.Count | Should -BeGreaterThan 0 + } + + It 'creates no steppable pipeline inside a pipeline stage fed by a command' { + $Violations = @( + foreach ($File in $script:Candidates) { + $Errs = $null + $Ast = [System.Management.Automation.Language.Parser]::ParseFile($File, [ref]$null, [ref]$Errs) + if ($Errs.Count -gt 0) { continue } + Get-SteppableScopeViolation -Ast $Ast -Path $File + } + ) + + $Report = $Violations | ForEach-Object { + "{0}:{1} - writer created inside '{2}' fed by '{3}'; hoist it above the pipeline and gate End() on the row count" -f + (Split-Path $_.Path -Leaf), $_.Line, $_.Stage, $_.Upstream + } + + $Violations.Count | Should -Be 0 -Because "these call sites capture the upstream command's scope:`n$($Report -join "`n")" + } + } +} diff --git a/version_latest.txt b/version_latest.txt index 6d7912d3c22d9..f60f38dc9bbe5 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.8.2 \ No newline at end of file +10.8.3 \ No newline at end of file