Public/AccountSecurity.ps1
|
#region Account Security - Allowed IP Ranges (MFA trusted-IP allowlist) # # The account-level "trusted IP" allowlist is stored under the account MFA # configuration, not connector configuration: # # GET /users/{userId}/mfa (read; works with normal credentials) # PUT /users/{userId}/mfa (write; requires PRIMARY credentials whose role # grants the "Enable and configure MFA" permission, # e.g. Master Admin) # # The config looks like: # <mfa> # <enabled>true|false</enabled> # <rules> # <and> # <totp></totp> # <or> # <ip-range><from>10.20.0.0</from><to>10.20.255.255</to></ip-range> # <ip-range><cidr>10.20.0.0/16</cidr></ip-range> # <ip-range><ip>192.168.1.0</ip><mask>24</mask></ip-range> # </or> # </and> # </rules> # </mfa> # # GROUP NESTING IS SIGNIFICANT (TAC-342). Every rule in an <and> group must match. # Two or more <ip-range> rules as direct <and> siblings can never all match the # one source address of a request, so such an allowlist denies access to everyone # once MFA enforcement is on. Trusted-IP ranges are therefore alternatives and # must share an <or> group, which the WebApp nests inside the outer <and> next to # the MFA method. This module writes the same shape and reads <ip-range> rules at # any depth. # # An <ip-range> may be stored as <from>/<to>, as <cidr>, or as <ip>+<mask>. This # module WRITES from/to only: the WebApp "IP Ranges" page hangs on CIDR-based # rules (MR !44), so each input CIDR is converted to its from/to range on write. # Get-KeepitAllowedIPRange reads all three forms and reports the equivalent CIDR # whenever a from/to range is one aligned CIDR block. # # IP-range rules share the <rules> group with the MFA method (<totp>), so writes # are read-modify-write: <enabled>, the outer group operator, and all non-ip rules # are preserved; only the <ip-range> entries are managed here. <# .SYNOPSIS Converts a dotted-quad IPv4 string to its host-order UInt32 value. #> function ConvertTo-KeepitIPv4UInt32 { [CmdletBinding()] [OutputType([uint32])] param( [Parameter(Mandatory = $true)] [string]$Address ) $bytes = [System.Net.IPAddress]::Parse($Address).GetAddressBytes() [Array]::Reverse($bytes) return [System.BitConverter]::ToUInt32($bytes, 0) } <# .SYNOPSIS Converts a host-order UInt32 value back to a dotted-quad IPv4 string. #> function ConvertFrom-KeepitIPv4UInt32 { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $true)] [uint32]$Value ) $bytes = [System.BitConverter]::GetBytes($Value) [Array]::Reverse($bytes) return ([System.Net.IPAddress]::new($bytes)).ToString() } <# .SYNOPSIS Validates an IPv4 CIDR string (a.b.c.d/nn with octets 0-255 and prefix 0-32). #> function Test-KeepitIPv4Cidr { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Cidr ) if ($Cidr -notmatch '^\s*(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})/(\d{1,2})\s*$') { return $false } $octets = $Matches[1], $Matches[2], $Matches[3], $Matches[4] foreach ($o in $octets) { if ([int]$o -gt 255) { return $false } } $prefix = [int]$Matches[5] if ($prefix -lt 0 -or $prefix -gt 32) { return $false } return $true } <# .SYNOPSIS Computes the inclusive From/To address range for an IPv4 CIDR or ip+prefix. .OUTPUTS PSCustomObject with From, To (dotted-quad strings) and PrefixLength (int). #> function Get-KeepitIPv4Range { [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true)] [string]$Address, [Parameter(Mandatory = $true)] [ValidateRange(0, 32)] [int]$PrefixLength ) $ipUint = ConvertTo-KeepitIPv4UInt32 -Address $Address $maskUint = if ($PrefixLength -eq 0) { [uint32]0 } else { [uint32]((0xFFFFFFFFL -shl (32 - $PrefixLength)) -band 0xFFFFFFFFL) } $network = [uint32]($ipUint -band $maskUint) $broadcast = [uint32]($network -bor ((-bnot $maskUint) -band 0xFFFFFFFFL)) return [PSCustomObject]@{ From = ConvertFrom-KeepitIPv4UInt32 -Value $network To = ConvertFrom-KeepitIPv4UInt32 -Value $broadcast PrefixLength = $PrefixLength } } <# .SYNOPSIS Derives the CIDR notation for an inclusive From/To range, when the range is exactly one aligned CIDR block. .DESCRIPTION The account MFA endpoint stores IP-range rules as <from>/<to>. A from/to pair equals a single CIDR only when its size is a power of two and the start address is aligned to that size. Returns the CIDR string (e.g. '10.20.0.0/16') in that case, or $null when the range does not map to one CIDR block. .OUTPUTS String CIDR, or $null. #> function ConvertTo-KeepitCidrFromRange { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $true)] [string]$From, [Parameter(Mandatory = $true)] [string]$To ) $fromU = [uint64](ConvertTo-KeepitIPv4UInt32 -Address $From) $toU = [uint64](ConvertTo-KeepitIPv4UInt32 -Address $To) if ($toU -lt $fromU) { return $null } $size = $toU - $fromU + 1 # inclusive count, as UInt64 to allow 2^32 # Size must be a power of two. if (($size -band ($size - 1)) -ne 0) { return $null } $prefix = 32 $s = $size while ($s -gt 1) { $s = $s -shr 1; $prefix-- } # Start address must be aligned to the block size. if (($fromU % $size) -ne 0) { return $null } return "$From/$prefix" } <# .SYNOPSIS Converts one <ip-range> XML element to a From/To range object. .DESCRIPTION Internal helper. Reads the three storage forms the API accepts (<cidr>, <ip>+<mask>, and <from>/<to>) and returns the equivalent inclusive range. Returns $null for an element in none of those forms. .OUTPUTS PSCustomObject with From, To, Cidr, PrefixLength and Notation, or $null. #> function ConvertFrom-KeepitIPRangeNode { [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true)] [System.Xml.XmlElement]$Node ) $cidr = $null $prefix = $null $cidrNode = $Node.ChildNodes | Where-Object { $_.Name -eq 'cidr' } | Select-Object -First 1 $ipNode = $Node.ChildNodes | Where-Object { $_.Name -eq 'ip' } | Select-Object -First 1 $maskNode = $Node.ChildNodes | Where-Object { $_.Name -eq 'mask' } | Select-Object -First 1 $fromNode = $Node.ChildNodes | Where-Object { $_.Name -eq 'from' } | Select-Object -First 1 $toNode = $Node.ChildNodes | Where-Object { $_.Name -eq 'to' } | Select-Object -First 1 if ($cidrNode) { $cidr = $cidrNode.InnerText.Trim() $address, $prefixStr = $cidr -split '/' $prefix = [int]$prefixStr $range = Get-KeepitIPv4Range -Address $address -PrefixLength $prefix $notation = 'cidr' } elseif ($ipNode -and $maskNode) { $address = $ipNode.InnerText.Trim() $prefix = [int]$maskNode.InnerText.Trim() $range = Get-KeepitIPv4Range -Address $address -PrefixLength $prefix $notation = 'ip-mask' } elseif ($fromNode -and $toNode) { $range = [PSCustomObject]@{ From = $fromNode.InnerText.Trim() To = $toNode.InnerText.Trim() } $notation = 'range' # Rules written by this module are stored as from/to; surface the # equivalent CIDR when the range is one aligned block so callers # still see .Cidr / .PrefixLength. $derivedCidr = ConvertTo-KeepitCidrFromRange -From $range.From -To $range.To if ($derivedCidr) { $cidr = $derivedCidr $prefix = [int]($derivedCidr -split '/')[1] } } else { return $null } return [PSCustomObject]@{ From = $range.From To = $range.To Cidr = $cidr PrefixLength = $prefix Notation = $notation } } <# .SYNOPSIS Enumerates the <ip-range> rules in an MFA configuration, at any depth. .DESCRIPTION Internal helper. The rules group nests: the WebApp stores trusted-IP ranges in an <or> group inside the outer <and> group, so IP-range rules are not always direct children of the outer group. This walks every <ip-range> element under <rules> and reports the operator of the group that holds each one. .OUTPUTS PSCustomObject[] with From, To, Cidr, PrefixLength, Notation and Operator. #> function Get-KeepitIPRangeRuleInternal { [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true)] [xml]$MfaXml ) $rulesNode = $MfaXml.SelectSingleNode('/mfa/rules') if ($null -eq $rulesNode) { Write-Verbose "No <rules> element present; no IP ranges configured." return } foreach ($node in $rulesNode.SelectNodes('.//ip-range')) { $range = ConvertFrom-KeepitIPRangeNode -Node $node if ($null -eq $range) { Write-Warning "Skipping an <ip-range> rule in an unrecognised format: $($node.OuterXml)" continue } [PSCustomObject]@{ From = $range.From To = $range.To Cidr = $range.Cidr PrefixLength = $range.PrefixLength Notation = $range.Notation Operator = $node.ParentNode.Name } } } <# .SYNOPSIS Warns when the IP-range rules in an MFA configuration deny access to everyone. .DESCRIPTION Internal helper. Two or more <ip-range> rules in the same <and> group must all match the one source address of a request. When their ranges do not overlap, no address can match, so an enabled allowlist locks out every user (TAC-342). #> function Write-KeepitIPRangeRuleWarning { [CmdletBinding()] [OutputType([void])] param( [Parameter(Mandatory = $true)] [xml]$MfaXml ) $rulesNode = $MfaXml.SelectSingleNode('/mfa/rules') if ($null -eq $rulesNode) { return } foreach ($group in $rulesNode.SelectNodes('.//and')) { $nodes = @($group.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.Name -eq 'ip-range' }) if ($nodes.Count -lt 2) { continue } $ranges = @($nodes | ForEach-Object { ConvertFrom-KeepitIPRangeNode -Node $_ } | Where-Object { $_ }) if ($ranges.Count -lt 2) { continue } $widestFrom = ($ranges | ForEach-Object { ConvertTo-KeepitIPv4UInt32 -Address $_.From } | Measure-Object -Maximum).Maximum $narrowestTo = ($ranges | ForEach-Object { ConvertTo-KeepitIPv4UInt32 -Address $_.To } | Measure-Object -Minimum).Minimum if ($widestFrom -gt $narrowestTo) { Write-Warning ("The allowlist cannot be satisfied: $($ranges.Count) IP-range rules are combined with AND, and the ranges do not overlap, " + "so no address matches all of them. While MFA enforcement is on, this denies access to every user. " + "Run Update-KeepitAllowedIPRange to rewrite the ranges as alternatives (an OR group).") } else { Write-Warning ("$($ranges.Count) IP-range rules are combined with AND, so only addresses inside every one of them are allowed. " + "Run Update-KeepitAllowedIPRange to rewrite the ranges as alternatives (an OR group).") } } } <# .SYNOPSIS Retrieves the raw account MFA configuration element (<mfa>...</mfa>) as text. .DESCRIPTION Internal helper. Returns a hashtable with the raw <mfa> string (declaration and trailing comment stripped) and the parsed [xml] document. #> function Get-KeepitMfaConfigInternal { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$AuthHeader, [Parameter(Mandatory = $true)] [string]$BaseUrl, [Parameter(Mandatory = $true)] [string]$UserId ) $headers = @{ 'Authorization' = $AuthHeader 'Accept' = 'application/vnd.keepit.v4+xml' } $uri = "$BaseUrl/users/$UserId/mfa" Write-Verbose "GET $uri" $raw = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers -ErrorAction Stop if ($raw -is [System.Xml.XmlDocument]) { $rawText = $raw.OuterXml } elseif ($raw -is [byte[]]) { $rawText = [System.Text.Encoding]::UTF8.GetString($raw) } else { $rawText = [string]$raw } # Isolate just the <mfa>...</mfa> element (drop xml declaration / trailing comment) $match = [regex]::Match($rawText, '(?s)<mfa>.*</mfa>') if (-not $match.Success) { throw "Unexpected MFA response: could not locate an <mfa> element." } $mfaXml = $match.Value return @{ Text = $mfaXml Xml = [xml]$mfaXml } } <# .SYNOPSIS Retrieves the account's allowed IP address ranges (trusted-IP allowlist). .DESCRIPTION Reads the account MFA configuration (GET /users/{userId}/mfa) and returns the configured IP-range rules. Each rule is returned as an inclusive address range (From/To), regardless of whether the API stores it as <from>/<to>, <cidr>, or <ip>+<mask>. A range is the one representation common to all three, so Get-KeepitAllowedIPRange always returns a range. When a range is exactly one aligned CIDR block, the .Cidr and .PrefixLength properties are also populated; they are $null for an arbitrary range that is not a single CIDR. Rules are reported at any depth in the rules group, so ranges added in the Keepit WebApp (which nests them in an <or> group) are listed too. The .Operator property names the group that holds each rule: 'or' means the ranges are alternatives, and any address in any range is allowed. Several rules with Operator 'and' must all match one source address, which usually cannot happen; this cmdlet warns when it finds such a set. Note: Update-KeepitAllowedIPRange writes rules as <from>/<to> (the WebApp cannot render CIDR-based rules), so ranges written by this module read back with Notation 'range' and a derived .Cidr. Reading the allowlist works with normal (cached or -Credential) credentials. Changing it requires Update-KeepitAllowedIPRange with primary credentials. .PARAMETER Credential Optional PSCredential used to build a fresh authentication header for this call. If omitted, the cached credentials from Connect-KeepitService are used. .PARAMETER Environment Optional Keepit environment override (e.g. 'us-dc'). If omitted, the cached environment from Connect-KeepitService is used. .PARAMETER Raw Return the raw <mfa> configuration XML instead of parsed range objects. .EXAMPLE Get-KeepitAllowedIPRange Lists the account's configured allowed IP ranges as From/To range objects. .EXAMPLE Get-KeepitAllowedIPRange -Raw Returns the raw MFA configuration XML, including the <enabled> flag and the rules group operator. .OUTPUTS PSCustomObject[] - one object per IP-range rule with properties: - From: First address in the range (dotted-quad) - To: Last address in the range (dotted-quad) - Cidr: The CIDR string when the rule maps to one CIDR block (stored as <cidr>, or a from/to range that is a single aligned block), otherwise $null - PrefixLength: The prefix length (from CIDR or <mask>) if known - Notation: How the rule is stored ('cidr', 'ip-mask', or 'range') - Operator: The operator of the group holding the rule ('or' = the ranges are alternatives; 'and' = every rule in the group must match) String - Raw <mfa> XML when -Raw is specified. .NOTES Ranges are only enforced when account MFA is enabled and the account uses the trusted-IP rules for access control. Use -Raw to inspect the <enabled> flag and the full rules structure. #> function Get-KeepitAllowedIPRange { [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $false)] [PSCredential]$Credential, [Parameter(Mandatory = $false)] [ValidateScript({ $_ -in $script:ValidKeepitEnvironments })] [string]$Environment, [switch]$Raw ) try { Write-Verbose "=== Get-KeepitAllowedIPRange ===" $authHeader = Get-AuthHeader -Credential $Credential $baseUrl = Get-KeepitBaseUrl -Environment $Environment $userId = Get-KeepitUserId -AuthHeader $authHeader -BaseUrl $baseUrl Write-Verbose "User ID: $userId" $mfa = Get-KeepitMfaConfigInternal -AuthHeader $authHeader -BaseUrl $baseUrl -UserId $userId if ($Raw) { return $mfa.Text } # Report every <ip-range> rule, at whatever depth it sits in the rules # group, and warn when the combination denies access to everyone. Write-KeepitIPRangeRuleWarning -MfaXml $mfa.Xml Get-KeepitIPRangeRuleInternal -MfaXml $mfa.Xml } catch { $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Failed to retrieve allowed IP ranges: $($_.Exception.Message)", $_.Exception), 'KeepitApiError', [System.Management.Automation.ErrorCategory]::ConnectionError, $null ) ) } } <# .SYNOPSIS Sets the account's allowed IP address ranges (trusted-IP allowlist). .DESCRIPTION Replaces the IP-range rules in the account MFA configuration (PUT /users/{userId}/mfa) with the supplied set of IPv4 CIDR ranges. This is a read-modify-write operation: the <enabled> flag, the outer rules group operator, and all non-IP rules (such as the <totp> MFA method) are preserved; every existing <ip-range> entry, at any depth, is replaced. The ranges are written as alternatives, in one <or> group inside the outer rules group, which is the structure the Keepit WebApp uses. Any address in any of the ranges is then allowed. Each input CIDR is written as a <from>/<to> address range (the CIDR's network and broadcast addresses), not as a <cidr> element. The Keepit WebApp "IP Ranges" page hangs on CIDR-based rules, so from/to is the storage form the frontend renders safely. Get-KeepitAllowedIPRange reports the equivalent CIDR on read-back. Because -IPRange replaces the full allowlist, include every range you want to keep. To add to the existing list, read it first with Get-KeepitAllowedIPRange (see the BulkSiteConfig/IPAllowlist example). To remove every range and empty the allowlist, use -Clear instead of -IPRange; this removes all IP-range rules, including ranges added in the WebApp, while preserving the <enabled> flag and non-IP rules such as TOTP. IMPORTANT: Writing MFA/security settings requires PRIMARY account credentials (a user login, not an API token) whose role grants the "Enable and configure MFA" permission (for example, Master Admin). A non-primary token is rejected with "Primary credentials required"; a primary login without the MFA permission is rejected with "Forbidden". Supply such credentials via -Credential. This cmdlet does NOT change the <enabled> flag. On an account with enforcement on, an allowlist that does not include your own public IP address denies you access, so review the account's MFA state (Get-KeepitAllowedIPRange -Raw) before you enable enforcement. .PARAMETER IPRange One or more IPv4 CIDR ranges (e.g. '10.20.0.0/16') that make up the complete desired allowlist. Replaces any existing IP-range rules. The ranges are written as alternatives (an <or> group), so an address in any one of them is allowed. .PARAMETER Clear Remove every IP-range rule, emptying the allowlist. Mutually exclusive with -IPRange. The <enabled> flag, the outer rules group operator, and non-IP rules (such as TOTP) are preserved. .PARAMETER Credential PSCredential for a primary account whose role can configure MFA. Recommended for every call; if omitted, cached credentials are used and the write will fail unless the cached session is itself a primary, MFA-capable login. .PARAMETER Environment Optional Keepit environment override (e.g. 'us-dc'). If omitted, the cached environment from Connect-KeepitService is used. .PARAMETER PassThru Return the updated allowed IP ranges (as Get-KeepitAllowedIPRange would) after a successful update. .EXAMPLE Update-KeepitAllowedIPRange -IPRange '203.0.113.0/24' -Credential $primaryCred Sets the allowlist to a single /24 range, replacing any existing ranges. .EXAMPLE Update-KeepitAllowedIPRange -IPRange '10.20.0.0/16','10.30.0.0/16','10.40.0.0/16' -Credential $primaryCred -Confirm:$false Sets three /16 trusted ranges in one operation without prompting. The ranges are alternatives: an address in any one of the three is allowed. .EXAMPLE $keep = Get-KeepitAllowedIPRange | ForEach-Object { $_.Cidr } | Where-Object { $_ } Update-KeepitAllowedIPRange -IPRange (@($keep) + '198.51.100.0/24') -Credential $primaryCred Appends a range to the existing CIDR-defined allowlist. .EXAMPLE Update-KeepitAllowedIPRange -Clear -Credential $primaryCred Removes every trusted-IP range, emptying the allowlist (TOTP and the enabled flag are left unchanged). .OUTPUTS None by default. With -PassThru, the updated PSCustomObject range list. .NOTES Requires primary credentials with the "Enable and configure MFA" permission. Supports -WhatIf and -Confirm (ConfirmImpact = High). #> function Update-KeepitAllowedIPRange { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'Set')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'Set')] [ValidateNotNullOrEmpty()] [string[]]$IPRange, [Parameter(Mandatory = $true, ParameterSetName = 'Clear')] [switch]$Clear, [Parameter(Mandatory = $false)] [PSCredential]$Credential, [Parameter(Mandatory = $false)] [ValidateScript({ $_ -in $script:ValidKeepitEnvironments })] [string]$Environment, [switch]$PassThru ) try { Write-Verbose "=== Update-KeepitAllowedIPRange ===" $clearing = [bool]$Clear # Validate and normalise the requested ranges up front. In -Clear mode the # desired list is empty, which removes every IP-range rule from the group. $normalized = [System.Collections.Generic.List[string]]::new() if (-not $clearing) { foreach ($entry in $IPRange) { $candidate = $entry.Trim() if (-not (Test-KeepitIPv4Cidr -Cidr $candidate)) { throw "Invalid IPv4 CIDR range '$entry'. Expected a.b.c.d/nn with octets 0-255 and a prefix of 0-32 (e.g. '10.20.0.0/16')." } if (-not $normalized.Contains($candidate)) { $normalized.Add($candidate) } } } $authHeader = Get-AuthHeader -Credential $Credential $baseUrl = Get-KeepitBaseUrl -Environment $Environment $userId = Get-KeepitUserId -AuthHeader $authHeader -BaseUrl $baseUrl Write-Verbose "User ID: $userId" # Read current config so we can preserve enabled/operator/non-ip rules $mfa = Get-KeepitMfaConfigInternal -AuthHeader $authHeader -BaseUrl $baseUrl -UserId $userId # Edit the returned document in place, so anything this module does not # manage (the <enabled> flag, the MFA method, any future element) survives. $doc = $mfa.Xml $root = $doc.DocumentElement $enabledNode = $root.SelectSingleNode('enabled') if ($null -eq $enabledNode) { $enabledNode = $doc.CreateElement('enabled') $enabledNode.InnerText = 'false' [void]$root.PrependChild($enabledNode) } $enabled = $enabledNode.InnerText.Trim() $rulesNode = $root.SelectSingleNode('rules') if ($null -eq $rulesNode) { $rulesNode = $doc.CreateElement('rules') [void]$root.AppendChild($rulesNode) } # The outer group holds the MFA method and the trusted-IP group. Keep its # operator: rewriting <or> to <and> would start requiring TOTP *and* a # trusted IP. A missing group is created as <and>, the shape the WebApp # writes; with only IP ranges inside it, <and> of one <or> group means the # same thing as <or> on its own. $groupNode = $rulesNode.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.Name -in @('and', 'or') } | Select-Object -First 1 if ($null -eq $groupNode) { $groupNode = $doc.CreateElement('and') [void]$rulesNode.AppendChild($groupNode) Write-Verbose "No existing MFA rules group found; created an <and> group." } # Drop every existing <ip-range> rule at any depth. Ranges added in the # WebApp sit in a nested <or> group, so a shallow sweep of the outer group # left them in place and -Clear did not clear them (TAC-342). $existing = @($rulesNode.SelectNodes('.//ip-range')) foreach ($old in $existing) { [void]$old.ParentNode.RemoveChild($old) } Write-Verbose "Removed $($existing.Count) existing ip-range rule(s)." # Remove nested groups the sweep left empty, deepest first, so no <or></or> # husk remains. The outer group is kept even when empty. $nestedGroups = @($rulesNode.SelectNodes('.//and | .//or')) [Array]::Reverse($nestedGroups) foreach ($nested in $nestedGroups) { if ([object]::ReferenceEquals($nested, $groupNode)) { continue } if (-not ($nested.ChildNodes | Where-Object { $_.NodeType -eq 'Element' })) { [void]$nested.ParentNode.RemoveChild($nested) } } # Write the new entries as <from>/<to>. Each CIDR maps to a single contiguous # range (network address .. broadcast address). We store from/to rather than # <cidr> because the WebApp "IP Ranges" page hangs on CIDR-based rules # (MR !44); from/to is the representation the frontend renders safely. # # Trusted-IP ranges are alternatives, so they must share an <or> group. As # direct <and> siblings they could never all match one source address, and # enabling enforcement locked out every user (TAC-342). if ($normalized.Count -gt 0) { $ipParent = if ($groupNode.Name -eq 'or') { # The outer group already ORs its rules; nesting another <or> would # add nothing. $groupNode } else { $orGroup = $doc.CreateElement('or') [void]$groupNode.AppendChild($orGroup) $orGroup } foreach ($cidr in $normalized) { $address, $prefixStr = $cidr -split '/' $range = Get-KeepitIPv4Range -Address $address -PrefixLength ([int]$prefixStr) $rangeNode = $doc.CreateElement('ip-range') $fromNode = $doc.CreateElement('from') $fromNode.InnerText = $range.From $toNode = $doc.CreateElement('to') $toNode.InnerText = $range.To [void]$rangeNode.AppendChild($fromNode) [void]$rangeNode.AppendChild($toNode) [void]$ipParent.AppendChild($rangeNode) } } $newMfa = $root.OuterXml Write-Verbose "New MFA body: $newMfa" $rangeList = ($normalized -join ', ') if ($enabled -eq 'true' -and $normalized.Count -gt 0) { Write-Warning ("Account MFA enforcement is on, so this allowlist takes effect at once. Confirm that your own public IP address " + "is inside one of these ranges before you continue: $rangeList") } $target = "account $userId MFA trusted-IP allowlist" $action = if ($clearing) { "Remove all allowed IP ranges (empty the allowlist)" } else { "Set allowed IP ranges to: $rangeList" } if (-not $PSCmdlet.ShouldProcess($target, $action)) { return } $headers = @{ 'Authorization' = $authHeader 'Accept' = 'application/vnd.keepit.v4+xml' 'Content-Type' = 'application/xml' } $uri = "$baseUrl/users/$userId/mfa" Write-Verbose "PUT $uri" try { $response = Invoke-RestMethod -Uri $uri -Method Put -Headers $headers -Body $newMfa -ErrorAction Stop Write-Verbose "Update response: $response" } catch { $detail = $null if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $detail = $_.ErrorDetails.Message } else { $detail = $_.Exception.Message } if ($detail -match 'Primary credentials') { throw "Updating the IP allowlist requires primary account credentials (a user login), not an API token. Pass -Credential with your primary Keepit account credentials. (Server said: $detail)" } elseif ($detail -match 'Forbidden' -or $_.Exception.Response.StatusCode.value__ -eq 403) { throw "Access denied updating the IP allowlist. The credential's role must include the 'Enable and configure MFA' permission (for example, Master Admin); roles such as SSO Admin cannot modify MFA settings. (Server said: $detail)" } throw } if ($clearing) { Write-Verbose "Allowed IP ranges cleared (allowlist emptied)." } else { Write-Verbose "Allowed IP ranges updated: $rangeList" } if ($PassThru) { Get-KeepitAllowedIPRange -Credential $Credential -Environment $Environment } } catch { $PSCmdlet.ThrowTerminatingError( [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Failed to update allowed IP ranges: $($_.Exception.Message)", $_.Exception), 'KeepitApiError', [System.Management.Automation.ErrorCategory]::WriteError, $null ) ) } } #endregion |