grouptag-utilities.psm1

# tagging and grouping rule serialization/deserialization
#
# I didn't generate proper grammar or use parsing library here. I also didn't use a lexer to tokenize symbols, etc.
# This is all based on a very crude recursive parser that reads out condition groups and conditions.
#
# I added mandatory brackets and parentheses to the string representation to make parsing easier LL(1). Open parenthesis
# always means you are parsing a condition group that ends with close parenthesis. Open bracket means you are parsing a
# condition that ends with a closing bracket. A rule begins with one condition group that may have multiple conditions
# or other conditions groups embedded within it that are separated by logical joins (and/or). A condition group may
# employ either 'and' or 'or' but not both, and condition groups can nest other condition groups.

# helpers for parsing/generating rules and conditions
function Resolve-ObjectAttributeForAccount
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [ValidateSet("AllowPasswordRequests","AllowSessionRequests","AllowSSHKeyRequests","AssetName","AssetTag","Description",
                     "DirectoryContainer","Disabled","DiscoveredGroupDistinguishedName","DiscoveredGroupName","DiscoveryJobName",
                     "DistinguishedName","DomainName","EffectiveProfileName","ProfileName","Name","NetBiosName","PartitionName",
                     "Platform","PlatformName","PlatformVersion","IsServiceAccount","ObjectSid","Tag",IgnoreCase=$true)]
        [string]$ObjectAttribute
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    $ObjectAttribute
}
function Resolve-ObjectAttributeForAsset
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [ValidateSet("AllowSessionRequests","Description","DirectoryContainer","Disabled","DiscoveredGroupDistinguishedName",
                     "DiscoveredGroupName","DiscoveryJobName","EffectiveProfileName","ProfileName","Name","NetworkAddress",
                     "PartitionName","Platform","PlatformName","PlatformVersion","Tag",IgnoreCase=$true)]
        [string]$ObjectAttribute
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    $ObjectAttribute
}
function Resolve-LogicalJoinType
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [ValidateSet("And","Or",IgnoreCase=$true)]
        [string]$LogicalJoinType
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    $LogicalJoinType
}

# convert object to string
function Convert-PredicateObjectToString
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [ValidateSet("IsTrue","IsFalse","Contains","DoesNotContain","StartsWith","EndsWith","EqualTo","NotEqualTo","RegexCompare",IgnoreCase=$true)]
        [string]$CompareType,
        [Parameter(Mandatory=$false)]
        [string]$CompareValue
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    switch ($CompareType)
    {
        "IsTrue" { " eq true"; break }
        "IsFalse" { " eq false"; break }
        "Contains" { " contains '$CompareValue'"; break }
        "DoesNotContain" { " notcontains '$CompareValue'"; break }
        "StartsWith" { " startswith '$CompareValue'"; break }
        "EndsWith" { " endswith '$CompareValue'"; break }
        "EqualTo" { " eq '$CompareValue'"; break }
        "NotEqualTo" { " ne '$CompareValue'"; break }
        "RegexCompare" { " match '$CompareValue'"; break }
        default {
            throw "Unrecognized CompareType '$CompareType' in Condition"
        }
    }
}
function Convert-LogicalJoinTypeToString
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [string]$LogicalJoinType
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    (" " + (Resolve-LogicalJoinType $LogicalJoinType).ToLower() + " ")
}
function Convert-ConditionToString
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [object]$Condition,
        [Parameter(Mandatory=$true)]
        [ValidateSet("account","asset",IgnoreCase=$true)]
        [string]$Type
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    if ($Condition.PSObject.Properties.Name -contains "ObjectAttribute")
    {
        if ($Condition.PSObject.Properties.Name -contains "CompareType")
        {
            if ($Condition.PSObject.Properties.Name -contains "CompareValue")
            {
                if ($Type -ieq "account")
                {
                    $local:String = (Resolve-ObjectAttributeForAccount $Condition.ObjectAttribute)
                }
                else
                {
                    $local:String = (Resolve-ObjectAttributeForAsset $Condition.ObjectAttribute)
                }
                ("[" + $local:String + (Convert-PredicateObjectToString $Condition.CompareType $Condition.CompareValue) + "]")
            }
            else
            {
                throw ("Condition does not include CompareValue: " + (ConvertTo-Json $Condition -Compress))
            }
        }
        else
        {
            throw ("Condition does not include CompareType: " + (ConvertTo-Json $Condition -Compress))
        }
    }
    else
    {
        throw ("Condition does not include ObjectAttribute: " + (ConvertTo-Json $Condition -Compress))
    }
}
function Convert-ConditionGroupToString
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [object]$ConditionGroup,
        [Parameter(Mandatory=$true)]
        [ValidateSet("account","asset",IgnoreCase=$true)]
        [string]$Type
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    if ($ConditionGroup.PSObject.Properties.Name -contains "LogicalJoinType")
    {
        if ($ConditionGroup.PSObject.Properties.Name -contains "Children")
        {
            if ($ConditionGroup.Children.TaggingGroupingCondition)
            {
                $local:String = $null
                foreach ($local:ChildCondition in $ConditionGroup.Children.TaggingGroupingCondition)
                {
                    if (-not $local:ChildCondition) { continue }
                    $local:ChildString = (Convert-ConditionToString $local:ChildCondition $Type)
                    if ($local:String)
                    {
                        $local:String += ((Convert-LogicalJoinTypeToString $ConditionGroup.LogicalJoinType) + $local:ChildString)
                    }
                    else
                    {
                        $local:String = $local:ChildString
                    }
                }
            }
            if ($ConditionGroup.Children.TaggingGroupingConditionGroup)
            {
                $local:GroupString = $null
                foreach ($local:ChildConditionGroup in $ConditionGroup.Children.TaggingGroupingConditionGroup)
                {
                    if (-not $local:ChildConditionGroup) { continue }
                    $local:Recurse = ("(" + (Convert-ConditionGroupToString $local:ChildConditionGroup $Type) + ")")
                    if ($local:GroupString)
                    {
                        $local:GroupString += ((Convert-LogicalJoinTypeToString $ConditionGroup.LogicalJoinType) + $local:Recurse)
                    }
                    else
                    {
                        $local:GroupString = $local:Recurse
                    }
                }
            }
            if ($local:String -and $local:GroupString)
            {
                ($local:String + (Convert-LogicalJoinTypeToString $ConditionGroup.LogicalJoinType) + $local:GroupString)
            }
            elseif ($local:String)
            {
                $local:String
            }
            elseif ($local:GroupString)
            {
                $local:GroupString
            }
        }
        else
        {
            throw ("ConditionGroup does not include Children: " + (ConvertTo-Json $ConditionGroup -Compress))
        }
    }
    else
    {
        throw ("ConditionGroup does not include LogicalJoinType: " + (ConvertTo-Json $ConditionGroup -Compress))
    }
}
function Convert-RuleToString
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [object]$Rule,
        [Parameter(Mandatory=$true)]
        [ValidateSet("account","asset",IgnoreCase=$true)]
        [string]$Type
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    ("(" + (Convert-ConditionGroupToString $Rule.RuleConditionGroup $Type) + ")")
}

function Convert-StringToCompareValue
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [string]$String
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    if (-not $String.StartsWith("'") -or -not $String.EndsWith("'"))
    {
        throw "Value string not properly quoted with single quote when parsing predicate: $String"
    }

    $String.Trim('''')
}

# convert string to object
function Convert-StringToPredicateObject
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [string]$String1,
        [Parameter(Mandatory=$true)]
        [string]$String2
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    $local:Trimmed1 = $String1.Trim()
    $local:Trimmed2 = $String2.Trim()
    switch ($local:Trimmed1)
    {
        "eq" {
            if ($local:Trimmed2 -ieq "true") { @{ CompareType = "IsTrue"; CompareValue = $null } }
            elseif ($local:Trimmed2 -ieq "false") { @{ CompareType = "IsFalse"; CompareValue = $null } }
            else { @{ CompareType = "EqualTo"; CompareValue = (Convert-StringToCompareValue $local:Trimmed2) } }
            break
        }
        "ne" {
            @{ CompareType = "NotEqualTo"; CompareValue = (Convert-StringToCompareValue $local:Trimmed2) }
            break
        }
        "contains" {
            @{ CompareType = "Contains"; CompareValue = (Convert-StringToCompareValue $local:Trimmed2) }
            break
        }
        "notcontains" {
            @{ CompareType = "DoesNotContain"; CompareValue = (Convert-StringToCompareValue $local:Trimmed2) }
            break
        }
        "startswith" {
            @{ CompareType = "StartsWith"; CompareValue = (Convert-StringToCompareValue $local:Trimmed2) }
            break
        }
        "endswith" {
            @{ CompareType = "EndsWith"; CompareValue = (Convert-StringToCompareValue $local:Trimmed2) }
            break
        }
        "match" {
            @{ CompareType = "RegexCompare"; CompareValue = (Convert-StringToCompareValue $local:Trimmed2) }
            break
        }
        default {
            throw "Unrecognized comparison type while parsing condition predicate: $String1 $String2"
        }
    }
}
function Convert-StringToCondition
{

    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [ref]$StringBuf,
        [Parameter(Mandatory=$true)]
        [ValidateSet("account","asset",IgnoreCase=$true)]
        [string]$Type
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    $local:Condition = (New-Object PSObject -Property @{
        ObjectAttribute = "Unknown";
        CompareType = "Unknown";
        CompareValue = "Unknown";
    })

    # First find the end of this condition--
    # Opening '[' is parsed off, and no nesting is allowed
    $local:ClosingBracket = $false;
    $local:InQuote = $false # only single quotes supported
    for ( ; $StringBuf.Value.Pos -lt $StringBuf.Value.Str.Length ; $StringBuf.Value.Pos++)
    {
        $local:Char = $StringBuf.Value.Str[$StringBuf.Value.Pos]
        if ($local:Char -eq ']' -and -not $InQuote)
        {
            $local:ClosingBracket = $true; $StringBuf.Value.Pos++; break;
        }
        elseif ($local:Char -eq '''')
        {
            $local:InQuote = (-not $local:InQuote)
        }
        $local:SubString += $local:Char
    }
    if (-not $local:ClosingBracket) { throw "Mismatched bracket while reading condition substring: $($StringBuf.Value.Str)" }

    # Start parsing children in this group
    # Powershell 6+ - an "enhancement" in .NET requires you to force a certain overload when calling string.split
    # using multiple characters as split-points, in this case we need to force the char[] overload.
    # https://github.com/PowerShell/PowerShell/issues/11720
    $local:StringParts = $local:SubString.Split([char[]] " `t`n", 3, [StringSplitOptions]::RemoveEmptyEntries)
    if ($local:StringParts.Count -ne 3)
    {
        throw "Conditions string did not parse into three parts [ObjectAttribute CompareType 'CompareValue']: $($local:SubString)"
    }
    if ($Type -ieq "account")
    {
        $local:Condition.ObjectAttribute = (Resolve-ObjectAttributeForAccount $local:StringParts[0])
    }
    else
    {
        $local:Condition.ObjectAttribute = (Resolve-ObjectAttributeForAsset $local:StringParts[0])
    }

    $local:Predicate = (Convert-StringToPredicateObject $local:StringParts[1] $local:StringParts[2])
    $local:Condition.CompareType = $local:Predicate.CompareType
    $local:Condition.CompareValue = $local:Predicate.CompareValue

    $local:Condition
}
function Convert-StringToConditionGroup
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [ref]$StringBuf,
        [Parameter(Mandatory=$true)]
        [ValidateSet("account","asset",IgnoreCase=$true)]
        [string]$Type
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    $local:ConditionGroup = (New-Object PSObject -Property @{
        LogicalJoinType = "And"
        Children = @()
    })

    # First, check that the condition group starts with opening parenthesis
    if ($StringBuf.Value.Str[$StringBuf.Value.Pos] -ne '(')
    {
        throw "Every condition group must be surrounded by parentheses: $($local:StringBuf.Value.Str)"
    }
    # Read the entire condition group into a substring
    $local:Parens = 0
    $local:InQuote = $false # only single quotes supported
    for ( ; $StringBuf.Value.Pos -lt $StringBuf.Value.Str.Length ; $StringBuf.Value.Pos++)
    {
        $local:Char = $StringBuf.Value.Str[$StringBuf.Value.Pos]
        $local:SubString += $local:Char
        if ($local:Char -eq ')')
        {
            if (-not $InQuote ) { $local:Parens-- }
            if ($local:Parens -eq 0) { $StringBuf.Value.Pos++; break; }
            elseif ($local:Parens -lt 0) { throw "Mismatched closing parenthesis while reading condition group substring: $($StringBuf.Value.Str)" }
        }
        elseif ($local:Char -eq '(')
        {
            if (-not $local:InQuote) { $local:Parens++ }
        }
        elseif ($local:Char -eq '''')
        {
            $local:InQuote = (-not $local:InQuote)
        }
    }
    if ($local:InQuote) { throw "Unterminated quote while reading condition group substring: $($StringBuf.Value.Str)" }
    if ($local:Parens -gt 0) { throw "Mismatched opening parenthesis while reading condition group substring: $($StringBuf.Value.Str)" }

    # Trim off parenthesis (cannot use TrimStart() and TrimEnd() as they remove all occurrences)
    $local:SubString = $local:SubString.Substring(1) # remove first char
    $local:SubString = $local:SubString.Substring(0, ($local:SubString.Length - 1)) # remove last char

    # Start parsing children in this group
    $local:SubStringBuf = (New-Object PSObject -Property @{ Str = $local:SubString; Pos = 0 })
    for ( ; $local:SubStringBuf.Pos -lt $local:SubStringBuf.Str.Length ; $local:SubStringBuf.Pos++)
    {
        $local:Char = $local:SubStringBuf.Str[$local:SubStringBuf.Pos]
        # Ignore superfluous whitespace
        if ($local:Char -in ' ','`t','`n') { continue }
        # Parse condition
        if ($local:Char -eq '[')
        {
            $local:SubStringBuf.Pos++
            $local:ConditionGroup.Children += (New-Object PSObject -Property @{
                TaggingGroupingCondition = (Convert-StringToCondition ([ref]$local:SubStringBuf) $Type);
                TaggingGroupingConditionGroup = $null;
            })
        }
        # Parse condition group
        elseif ($local:Char -eq '(')
        {
            $local:ConditionGroup.Children += (New-Object PSObject -Property @{
                TaggingGroupingCondition = $null;
                TaggingGroupingConditionGroup = (Convert-StringToConditionGroup ([ref]$local:SubStringBuf) $Type)
            })
        }
        # Parse logical join type -- this will be the same every time
        elseif ($local:Char -in 'a','A')
        {
            if ($local:SubStringBuf.Str[$local:SubStringBuf.Pos + 1] -in 'n','N' `
                -and $local:SubStringBuf.Str[$local:SubStringBuf.Pos + 2] -in 'd','D')
            {
                $local:ConditionGroup.LogicalJoinType = "And"
                $local:SubStringBuf.Pos += 2
            }
            else
            {
                throw "Unrecognized character at position $($local:SubStringBuf.Pos) in condition group substring: $($local:SubString.Str)"
            }
        }
        elseif ($local:Char -in 'o','O')
        {
            if ($local:SubStringBuf.Str[$local:SubStringBuf.Pos + 1] -in 'r','R')
            {
                $local:ConditionGroup.LogicalJoinType = "Or"
                $local:SubStringBuf.Pos++
            }
            else
            {
                throw "Unrecognized character at position $($local:SubStringBuf.Pos) in condition group substring: $($local:SubString.Str)"
            }
        }
        else
        {
            throw "Unrecognized character at position $($local:SubStringBuf.Pos) in condition group substring: $($local:SubString.Str)"
        }
    }

    $local:ConditionGroup
}
function Convert-StringToRule
{
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true)]
        [string]$String,
        [Parameter(Mandatory=$true)]
        [ValidateSet("account","asset",IgnoreCase=$true)]
        [string]$Type
    )

    if (-not $PSBoundParameters.ContainsKey("ErrorAction")) { $ErrorActionPreference = "Stop" }
    if (-not $PSBoundParameters.ContainsKey("Verbose")) { $VerbosePreference = $PSCmdlet.GetVariableValue("VerbosePreference") }

    $local:Rule = (New-Object PSObject -Property @{
        Enabled = $true;
        Description = $null;
        RuleConditionGroup = $null;
    })

    # remove whitespace and outer paren if superfluous parens included
    $local:Trimmed = $String.Trim()

    $local:StringBuf = (New-Object PSObject -Property @{ Str = $local:Trimmed; Pos = 0 })
    $local:Rule.RuleConditionGroup = (Convert-StringToConditionGroup ([ref]$local:StringBuf) $Type)

    $local:Rule
}