Classes/FeatureFlag.ps1
|
. $PSScriptRoot\..\Enums\Effect.ps1 enum Operator { Equals NotEquals GreaterThan GreaterThanOrEqual LessThan LessThanOrEqual In NotIn } class ConditionGroup { [object]$AllOf [object]$AnyOf [object]$Not [string]$Property [Operator]$Operator [object]$Value ConditionGroup([hashtable]$data) { if ($null -eq $data) { throw "Data cannot be null." } # Treat keys present but null as non-present (handles JSON round-trips where all # class fields are serialized including null/default ones). $groupKeys = @('AllOf', 'AnyOf', 'Not') | Where-Object { $data.ContainsKey($_) -and $null -ne $data[$_] } if ($groupKeys.Count -gt 1) { throw "ConditionGroup may only define one of: AllOf, AnyOf, Not. Got: $($groupKeys -join ', ')" } # Property presence (non-null) is the canonical signal for a flat/leaf condition. # Operator is excluded from this check because its enum default ("Equals") is always # serialized as a non-null string in JSON round-trips, making it unreliable as a signal. $hasGroup = $groupKeys.Count -gt 0 $hasProperty = $data.ContainsKey('Property') -and $null -ne $data['Property'] if ($hasGroup -and $hasProperty) { throw "ConditionGroup with AllOf, AnyOf, or Not cannot also have Property, Operator, and Value defined." } if ($hasProperty) { $hasOperator = $data.ContainsKey('Operator') -and $null -ne $data['Operator'] $hasValue = $data.ContainsKey('Value') -and $null -ne $data['Value'] if (-not $hasOperator -or -not $hasValue) { throw "ConditionGroup with Property must also have Operator and Value defined." } } if ($hasProperty) { $this.Property = $data.Property } if ($data.ContainsKey('Operator') -and $null -ne $data['Operator']) { $this.Operator = $data.Operator } if ($data.ContainsKey('Value') -and $null -ne $data['Value']) { $this.Value = $data.Value } if ($data.ContainsKey('AllOf') -and $null -ne $data['AllOf']) { $this.AllOf = $data.AllOf | ForEach-Object { [ConditionGroup]::new($_) } } if ($data.ContainsKey('AnyOf') -and $null -ne $data['AnyOf']) { $this.AnyOf = $data.AnyOf | ForEach-Object { [ConditionGroup]::new($_) } } if ($data.ContainsKey('Not') -and $null -ne $data['Not']) { $this.Not = $data.Not | ForEach-Object { [ConditionGroup]::new($_) } } } # Constructor for creating a new sub group ConditionGroup([string]$operator, [object[]]$conditions) { switch ($operator) { 'AllOf' { $this.AllOf = $conditions } 'AnyOf' { $this.AnyOf = $conditions } 'Not' { $this.Not = $conditions } default { throw "Unknown operator: $operator" } } } static [ConditionGroup] FromJson([string]$json) { $data = ConvertFrom-JsonToHashtable -InputObject $json return [ConditionGroup]::new($data) } [boolean]IsValid() { if ($null -ne $this.AllOf -or $null -ne $this.AnyOf -or $null -ne $this.Not) { return $true } return $null -ne $this.Property -and $null -ne $this.Operator -and $null -ne $this.Value } [string]ToString() { $sb = [System.Text.StringBuilder]::new() if ($this.AllOf) { [void]$sb.Append("AllOf(") $list = @() foreach ($condition in $this.AllOf) { $list += $condition.ToString() } [void]$sb.Append($list -join ', ') [void]$sb.Append(")") } if ($this.AnyOf) { [void]$sb.Append("AnyOf(") $list = @() foreach ($condition in $this.AnyOf) { $list += $condition.ToString() } [void]$sb.Append($list -join ', ') [void]$sb.Append(")") } if ($this.Not) { [void]$sb.Append("Not(") $list = @() foreach ($condition in $this.Not) { $list += $condition.ToString() } [void]$sb.Append($list -join ', ') [void]$sb.Append(")") } if ($null -ne $this.Property -and $null -ne $this.Operator -and $null -ne $this.Value) { [void]$sb.Append("$($this.Property) $($this.Operator) $($this.Value)") } return $sb.ToString() } } class Rule { [string]$Name [string]$Description [Effect]$Effect [ConditionGroup]$Conditions Rule([string]$Name) { $this.Name = $Name } Rule([hashtable]$data) { $this.Name = $data.Name $this.Description = $data.Description $this.Effect = $data.Effect if ($data.ContainsKey('Conditions')) { # Check if it's a condition group if ($data.Conditions -is [ConditionGroup]) { $this.Conditions = $data.Conditions } elseif ($data.Conditions -is [hashtable]) { $this.Conditions = [ConditionGroup]::new($data.Conditions) } else { throw "Unknown type for Conditions: $($data.Conditions.GetType().FullName)" } } } } class FeatureFlag { [string]$Name [string]$Description [string[]]$Tags [version]$Version [string]$Author [Effect]$DefaultEffect [Rule[]]$Rules [string]$FilePath FeatureFlag() {} FeatureFlag([hashtable]$data) { $this.Name = $data.Name $this.Description = $data.Description $this.Tags = $data.Tags if ($null -ne $data.Version) { if ($data.Version -is [System.Collections.IDictionary]) { $b = [int]$data.Version.Build $this.Version = if ($b -ge 0) { [version]"$($data.Version.Major).$($data.Version.Minor).$b" } else { [version]"$($data.Version.Major).$($data.Version.Minor)" } } else { $this.Version = [version]$data.Version } } $this.Author = $data.Author $this.DefaultEffect = $data.DefaultEffect $this.Rules = $data.Rules | ForEach-Object { [Rule]::new($_) } } # Example usage: # $json = Get-Content -Raw -Path 'd:\Gatekeeper\Gatekeeper\featureFlag.json' # $featureFlag = [FeatureFlag]::FromJson($json) static [FeatureFlag] FromJson([string]$json) { $data = ConvertFrom-JsonToHashtable -InputObject $json return [FeatureFlag]::new($data) } static [FeatureFlag] FromFile([string]$filePath) { if (-not (Test-Path $filePath)) { throw "File not found: $filePath" } $json = Get-Content -Raw -Path $filePath $featureFlag = [FeatureFlag]::FromJson($json) $featureFlag.FilePath = $filePath return $featureFlag } [void]Save() { if ($null -eq $this.FilePath) { throw "No file path specified to save FeatureFlag." } Write-Verbose "Saving FeatureFlag to file: $($this.FilePath)" $jsonParams = @{ Depth = 10 } # -EnumsAsStrings was introduced in PowerShell 7.2; on older hosts # (e.g. Windows PowerShell 5.1) enums serialize as their integer value, # which still round-trips back to the enum on load. if ((Get-Command ConvertTo-Json).Parameters.ContainsKey('EnumsAsStrings')) { $jsonParams['EnumsAsStrings'] = $true } $json = $this | ConvertTo-Json @jsonParams $tmpPath = "$($this.FilePath).tmp" Set-Content -Path $tmpPath -Value $json Move-Item -Path $tmpPath -Destination $this.FilePath -Force } } class GatekeeperPath { # Validates a user-supplied string as a safe path to an existing JSON file. # Rejects wildcard patterns, non-.json files, and UNC paths (unless enabled # via the Security.AllowUncPaths configuration setting) so callers that accept # untrusted flag names cannot be redirected to read arbitrary files. Returns # the resolved provider path on success and throws otherwise. static [string] ResolveJsonFilePath([string]$Path) { if ([string]::IsNullOrWhiteSpace($Path)) { throw "File path cannot be empty." } if ([System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($Path)) { throw "File path cannot contain wildcard characters: $Path" } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "File not found: $Path" } $resolved = (Resolve-Path -LiteralPath $Path).ProviderPath if ([System.IO.Path]::GetExtension($resolved) -ne '.json') { throw "File path must point to a .json file: $Path" } if (([uri]$resolved).IsUnc -and -not [GatekeeperPath]::AllowUncPaths()) { throw "UNC file paths are not permitted unless enabled via the Security.AllowUncPaths configuration: $Path" } return $resolved } static [boolean] AllowUncPaths() { try { return [boolean](Import-GatekeeperConfig).Security.AllowUncPaths } catch { return $false } } } class FeatureFlagTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute { ## Override the abstract method "Transform". This is where the user ## provided value will be inspected and transformed if possible. [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) { $item = switch ($inputData.GetType().FullName) { # Return the existing item if it's already a FeatureFlag 'FeatureFlag' { $inputData } 'System.Collections.Hashtable' { [FeatureFlag]::new($inputData) } 'System.String' { $resolvedPath = [GatekeeperPath]::ResolveJsonFilePath($inputData) $json = Get-Content -Raw -LiteralPath $resolvedPath [FeatureFlag]::FromJson($json) } default { throw "Cannot convert type to FeatureFlag: $($inputData.GetType().FullName)" } } return $item } [string] ToString() { return '[FeatureFlagTransformAttribute()]' } } class ConditionGroupTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute { ## Override the abstract method "Transform". This is where the user ## provided value will be inspected and transformed if possible. [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) { $item = switch ($inputData.GetType().FullName) { # Return the existing item if it's already a ConditionGroup 'ConditionGroup' { $inputData } 'System.Collections.Hashtable' { [ConditionGroup]::new($inputData) } 'System.String' { $resolvedPath = [GatekeeperPath]::ResolveJsonFilePath($inputData) $json = Get-Content -Raw -LiteralPath $resolvedPath [ConditionGroup]::FromJson($json) } default { throw "Cannot convert type to ConditionGroup: $($inputData.GetType().FullName)" } } return $item } [string] ToString() { return '[ConditionGroupTransformAttribute()]' } } |