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|or>
# <totp></totp>
# <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>
# </and|or>
# </rules>
# </mfa>
#
# 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: the group operator (<and>/<or>), <enabled>, 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
    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.

    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')

    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 rules group operator (<and>/<or>).
#>

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
        }

        # Locate the rules group (<and> or <or>) and its <ip-range> children
        $rulesNode = $mfa.Xml.mfa.rules
        if ($null -eq $rulesNode) {
            Write-Verbose "No <rules> element present; no IP ranges configured."
            return
        }

        $groupNode = $rulesNode.ChildNodes | Where-Object { $_.NodeType -eq 'Element' } | Select-Object -First 1
        if ($null -eq $groupNode) {
            Write-Verbose "Empty rules group; no IP ranges configured."
            return
        }

        $ipRangeNodes = $groupNode.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.Name -eq 'ip-range' }
        foreach ($node in $ipRangeNodes) {
            $cidr = $null
            $prefix = $null
            $address = $null
            $notation = $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) {
                $from = $fromNode.InnerText.Trim()
                $to = $toNode.InnerText.Trim()
                $range = [PSCustomObject]@{
                    From         = $from
                    To           = $to
                    PrefixLength = $null
                }
                $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
                # (and -Merge) still see .Cidr / .PrefixLength.
                $derivedCidr = ConvertTo-KeepitCidrFromRange -From $from -To $to
                if ($derivedCidr) {
                    $cidr = $derivedCidr
                    $prefix = [int]($derivedCidr -split '/')[1]
                }
            }
            else {
                Write-Warning "Skipping an <ip-range> rule in an unrecognised format: $($node.OuterXml)"
                continue
            }

            [PSCustomObject]@{
                From         = $range.From
                To           = $range.To
                Cidr         = $cidr
                PrefixLength = $prefix
                Notation     = $notation
            }
        }
    }
    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 rules group operator
    (<and>/<or>), and all non-IP rules (such as the <totp> MFA method) are
    preserved; only the <ip-range> entries are replaced.

    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
    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 or the rules group operator.
    On an enabled account whose rules use <and>, every rule must match, so a
    trusted-IP range that does not include the caller's address can deny access.
    Review the account's MFA state (Get-KeepitAllowedIPRange -Raw) before enabling
    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.
.PARAMETER Clear
    Remove every IP-range rule, emptying the allowlist. Mutually exclusive with
    -IPRange. The <enabled> flag, the 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.
.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

        $enabled = if ($mfa.Xml.mfa.enabled) { $mfa.Xml.mfa.enabled } else { 'false' }

        $rulesNode = $mfa.Xml.mfa.rules
        $groupNode = $null
        if ($null -ne $rulesNode) {
            $groupNode = $rulesNode.ChildNodes | Where-Object { $_.NodeType -eq 'Element' } | Select-Object -First 1
        }
        if ($groupNode) {
            $operator = $groupNode.Name    # 'and' or 'or'
        }
        else {
            # No existing group. Default to 'or' (least restrictive) to avoid
            # inadvertently locking access when enforcement is turned on.
            $operator = 'or'
            if (-not $clearing) {
                Write-Warning "No existing MFA rules group found; creating an <or> group for the IP ranges."
            }
        }

        # Preserve every non-ip-range rule (e.g. <totp>) verbatim
        $preserved = ''
        if ($groupNode) {
            foreach ($child in $groupNode.ChildNodes) {
                if ($child.NodeType -eq 'Element' -and $child.Name -ne 'ip-range') {
                    $preserved += $child.OuterXml
                }
            }
        }

        # Build the new ip-range 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.
        $ipRangeXml = ''
        foreach ($cidr in $normalized) {
            $address, $prefixStr = $cidr -split '/'
            $range = Get-KeepitIPv4Range -Address $address -PrefixLength ([int]$prefixStr)
            $ipRangeXml += "<ip-range><from>$($range.From)</from><to>$($range.To)</to></ip-range>"
        }

        $newMfa = "<mfa><enabled>$enabled</enabled><rules><$operator>$preserved$ipRangeXml</$operator></rules></mfa>"
        Write-Verbose "New MFA body: $newMfa"

        $rangeList = ($normalized -join ', ')
        $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