functions/Update-AssignmentScope.ps1

function Update-AssignmentScope {
<#
    Append, Set, or Delete a scope/notScopes selector inside one or more EPAC
    policy assignment definition files.
 
    Edits the "scope" (default) or "notScopes" block of every node in an EPAC
    assignment file (root and any nested entries under "children[]") that matches
    the optional NodeName / AssignmentName filters.
 
    Actions:
      Append - Add Values to the selector's array (dedupes). Creates the
                selector if it doesn't exist.
      Set - Overwrite the selector's array with Values. Creates the selector
                if it doesn't exist.
      Delete - Remove the entire selector key from the block.
 
    JSONC notes:
      - Input is parsed leniently (// and /* */ comments, trailing commas allowed).
      - Output is re-serialized as standard JSON. If the file contains comments
        they will be lost on save and the script warns before writing.
 
    Optional. File or folder. When omitted, defaults to
    "<repo>/Definitions/policyAssignments" relative to this script and recurses
    automatically. When a folder is supplied, pass -Recurse to descend into
    subfolders.
 
    Selector name inside the assignment file's "scope" block (e.g.
    TenantRootGroup, NonProd, EPAC-Prod). Mutually exclusive with -NotScopes.
 
    Selector name inside the assignment file's "notScopes" block. Mutually
    exclusive with -Scope.
 
    Append | Set | Delete
 
    Required for Append and Set. The full resource path(s), e.g.
    "/providers/Microsoft.Management/managementGroups/<id>".
 
    Optional. Only edit nodes whose "nodeName" property equals this value.
 
    Optional. Only edit nodes whose "assignment.name" property equals this value.
 
    When Path is a folder, descend into subfolders.
 
    Write a *.bak copy beside each modified file before saving.
 
    # Add a new selector "NonProd" with one MG path on every node
    .\Update-AssignmentScope.ps1 `
        -Path .\Definitions\policyAssignments\RestrictPublicAccess-Assignment-20260423.jsonc `
        -Scope NonProd -Action Append `
        -Values "/providers/Microsoft.Management/managementGroups/00000000-0000-0000-0000-000000000000"
 
    # Overwrite the TenantRootGroup selector on a specific node
    .\Update-AssignmentScope.ps1 -Path .\Definitions\policyAssignments\file.jsonc `
        -NodeName "TenantRootGroup/" -Scope TenantRootGroup -Action Set `
        -Values "/providers/Microsoft.Management/managementGroups/abc"
 
    # Remove the NonProd selector from every node in every file under the folder
    .\Update-AssignmentScope.ps1 -Path .\Definitions\policyAssignments -Recurse `
        -Scope NonProd -Action Delete -Backup
 
    # Append a path to the TenantRootGroup selector inside the notScopes block
    .\Update-AssignmentScope.ps1 `
        -NotScopes TenantRootGroup -Action Append `
        -Values "/subscriptions/00000000-0000-0000-0000-000000000000"
#>

[CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = 'Scope')]
param(
    [Parameter(Mandatory = $false)]
    [string] $Path,

    # Selector name inside the assignment file's "scope" block (e.g. TenantRootGroup, NonProd).
    [Parameter(Mandatory = $true, ParameterSetName = 'Scope')]
    [string] $Scope,

    # Selector name inside the assignment file's "notScopes" block. Mutually exclusive with -Scope.
    [Parameter(Mandatory = $true, ParameterSetName = 'NotScopes')]
    [string] $NotScopes,

    [Parameter(Mandatory = $true)]
    [ValidateSet('Append', 'Set', 'Delete')]
    [string] $Action,

    [string[]] $Values,

    [string] $NodeName,

    [string] $AssignmentName,

    [switch] $Recurse,

    [switch] $Backup
)

# Resolve which block ("scope" vs "notScopes") and selector name we're targeting
$blockKey = if ($PSCmdlet.ParameterSetName -eq 'NotScopes') { 'notScopes' } else { 'scope' }
$selector = if ($PSCmdlet.ParameterSetName -eq 'NotScopes') { $NotScopes } else { $Scope }

#region helpers
function ConvertFrom-JsoncText {
    param([string] $Text)

    # Strip block comments
    $stripped = [regex]::Replace($Text, '/\*[\s\S]*?\*/', '')

    # Strip line comments (skip // inside strings)
    $sb = [System.Text.StringBuilder]::new($stripped.Length)
    $inString = $false
    $escape = $false
    $i = 0
    while ($i -lt $stripped.Length) {
        $c = $stripped[$i]
        if ($escape) { [void]$sb.Append($c); $escape = $false; $i++; continue }
        if ($c -eq '\') { [void]$sb.Append($c); $escape = $true; $i++; continue }
        if ($c -eq '"') { $inString = -not $inString; [void]$sb.Append($c); $i++; continue }
        if (-not $inString -and $c -eq '/' -and ($i + 1) -lt $stripped.Length -and $stripped[$i + 1] -eq '/') {
            while ($i -lt $stripped.Length -and $stripped[$i] -ne "`n") { $i++ }
            continue
        }
        [void]$sb.Append($c); $i++
    }
    $clean = $sb.ToString()

    # Strip trailing commas before } or ]
    $clean = [regex]::Replace($clean, ',(\s*[}\]])', '$1')

    return ($clean | ConvertFrom-Json -AsHashtable -Depth 100)
}

function Test-HasComments {
    param([string] $Text)
    # Simple/cheap heuristic — false positives possible if // or /* appear in strings.
    return ($Text -match '(^|[^:])//' -or $Text -match '/\*')
}

function Update-ScopeBlock {
    <#
        Mutates a single node's scope (or notScopes) hashtable in place.
        Returns $true if the node was modified, $false otherwise.
    #>

    param(
        [hashtable] $Node,
        [string]    $BlockKey,    # 'scope' or 'notScopes'
        [string]    $Selector,
        [string]    $Action,
        [string[]]  $Values
    )

    # Ensure block exists for Append/Set; nothing to do for Delete on a missing block
    if (-not $Node.ContainsKey($BlockKey)) {
        if ($Action -eq 'Delete') { return $false }
        $Node[$BlockKey] = [ordered]@{}
    }

    $block = $Node[$BlockKey]
    # Coerce non-hashtable (e.g. PSCustomObject after rehydration) into hashtable
    if ($block -isnot [System.Collections.IDictionary]) {
        $coerced = [ordered]@{}
        foreach ($prop in $block.PSObject.Properties) { $coerced[$prop.Name] = $prop.Value }
        $block = $coerced
        $Node[$BlockKey] = $block
    }

    switch ($Action) {
        'Delete' {
            if ($block.Contains($Selector)) {
                $null = $block.Remove($Selector)
                return $true
            }
            return $false
        }
        'Set' {
            $block[$Selector] = @($Values)
            return $true
        }
        'Append' {
            $existing = @()
            if ($block.Contains($Selector) -and $null -ne $block[$Selector]) {
                $existing = @($block[$Selector])
            }
            $merged = [System.Collections.Generic.List[string]]::new()
            foreach ($v in $existing) { if ($v -and -not $merged.Contains($v)) { $merged.Add($v) } }
            $changed = $false
            foreach ($v in $Values) {
                if ($v -and -not $merged.Contains($v)) { $merged.Add($v); $changed = $true }
            }
            $block[$Selector] = $merged.ToArray()
            return $changed
        }
    }
}

function Invoke-NodeWalk {
    <#
        Recursively walks a node and its children[]. For each node that matches
        the filters, applies the mutation. Returns the count of modified nodes.
    #>

    param(
        [hashtable] $Node,
        [string]    $BlockKey,
        [string]    $Selector,
        [string]    $Action,
        [string[]]  $Values,
        [string]    $NodeNameFilter,
        [string]    $AssignmentNameFilter
    )

    $modified = 0

    $matchesNodeName = (-not $NodeNameFilter) -or ($Node.nodeName -eq $NodeNameFilter)
    $assignmentName  = if ($Node.ContainsKey('assignment') -and $Node.assignment) { $Node.assignment.name } else { $null }
    $matchesAssign   = (-not $AssignmentNameFilter) -or ($assignmentName -eq $AssignmentNameFilter)

    if ($matchesNodeName -and $matchesAssign) {
        # Only consider this node a candidate if it carries the relevant block
        # (or we're going to create one for Append/Set). To avoid touching the
        # purely-organizational root in files where edits aren't intended there,
        # we skip nodes that have neither scope nor notScopes nor any
        # assignment/parameter context — basically the bare root passthrough.
        $isRealNode = $Node.ContainsKey('scope') -or $Node.ContainsKey('notScopes') `
            -or $Node.ContainsKey('assignment') -or $Node.ContainsKey('parameters') `
            -or $Node.ContainsKey('enforcementMode')

        if ($isRealNode) {
            if (Update-ScopeBlock -Node $Node -BlockKey $BlockKey -Selector $Selector -Action $Action -Values $Values) {
                $modified++
            }
        }
    }

    if ($Node.ContainsKey('children') -and $Node.children) {
        foreach ($child in $Node.children) {
            if ($child -is [System.Collections.IDictionary]) {
                $modified += Invoke-NodeWalk -Node $child -BlockKey $BlockKey -Selector $Selector `
                    -Action $Action -Values $Values `
                    -NodeNameFilter $NodeNameFilter -AssignmentNameFilter $AssignmentNameFilter
            }
        }
    }

    return $modified
}
#endregion helpers

# Validate parameter combinations
if ($Action -in @('Append', 'Set') -and (-not $Values -or $Values.Count -eq 0)) {
    Write-Error "Action '$Action' requires -Values."
    exit 1
}
if ($Action -eq 'Delete' -and $Values) {
    Write-Warning "Values are ignored when Action=Delete."
}

# Resolve target files
$defaultFolderUsed = $false
if (-not $Path) {
    # Default: <repo>/Definitions/policyAssignments (script lives in Scripts/Helpers)
    $Path = Join-Path (Resolve-Path "$PSScriptRoot/../..").Path "Definitions/policyAssignments"
    $defaultFolderUsed = $true
    Write-Host "No -Path supplied; defaulting to '$Path' (recursive)."
}

if (-not (Test-Path -LiteralPath $Path)) {
    Write-Error "Path not found: $Path"
    exit 1
}
$resolved = Get-Item -LiteralPath $Path
$files = if ($resolved.PSIsContainer) {
    $recurseFolder = $Recurse -or $defaultFolderUsed
    Get-ChildItem -LiteralPath $resolved.FullName -Filter *.jsonc -File -Recurse:$recurseFolder
}
else {
    , $resolved
}

if (-not $files -or $files.Count -eq 0) {
    Write-Warning "No .jsonc files found at: $Path"
    exit 0
}

$totalFiles = 0
$totalNodes = 0

foreach ($file in $files) {
    $raw = Get-Content -LiteralPath $file.FullName -Raw
    if (-not $raw) { continue }

    try {
        $obj = ConvertFrom-JsoncText -Text $raw
    }
    catch {
        Write-Warning "Skipping $($file.FullName): JSON parse error — $($_.Exception.Message)"
        continue
    }

    if ($obj -isnot [System.Collections.IDictionary]) {
        Write-Warning "Skipping $($file.FullName): root is not an object."
        continue
    }

    $modified = Invoke-NodeWalk -Node $obj -BlockKey $blockKey -Selector $selector `
        -Action $Action -Values $Values `
        -NodeNameFilter $NodeName -AssignmentNameFilter $AssignmentName

    if ($modified -eq 0) {
        Write-Verbose "No changes for $($file.FullName)"
        continue
    }

    if (Test-HasComments -Text $raw) {
        Write-Warning "$($file.Name): file contains JSONC comments — they will be lost on save."
    }

    $newJson = $obj | ConvertTo-Json -Depth 100

    if ($PSCmdlet.ShouldProcess($file.FullName, "Update $modified node(s) — $blockKey/$selector/$Action")) {
        if ($Backup) {
            Copy-Item -LiteralPath $file.FullName -Destination "$($file.FullName).bak" -Force
        }
        Set-Content -LiteralPath $file.FullName -Value $newJson -Encoding utf8NoBOM
        Write-Host "Updated $modified node(s) in $($file.Name)"
        $totalFiles++
        $totalNodes += $modified
    }
}

Write-Host ""
Write-Host "Done. Modified $totalNodes node(s) across $totalFiles file(s)."
}