Private/ScheduledTask-Functions.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Scheduled task management functions for MakeMeAdminCLI.

.DESCRIPTION
    Provides functions to create, manage, and remove scheduled tasks
    for automatically removing users from the local Administrators group
    after their temporary admin rights expire.

.NOTES
    Author: MakeMeAdminCLI
    Version: 1.0.0
#>


# Default task settings
$script:DefaultTaskPath = "\Microsoft\Windows\MakeMeAdminCLI"
$script:PowerShellExe = "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe"

function Ensure-TaskFolderExists {
    <#
    .SYNOPSIS
        Ensures the Task Scheduler folder exists for MakeMeAdminCLI tasks.

    .DESCRIPTION
        Creates the task folder hierarchy if it doesn't exist.
        Uses the COM interface for maximum compatibility.

    .PARAMETER TaskPath
        The task path to create. Defaults to \Microsoft\Windows\MakeMeAdminCLI.
    #>

    [CmdletBinding()]
    param(
        [string]$TaskPath = $script:DefaultTaskPath
    )

    try {
        $scheduler = New-Object -ComObject Schedule.Service
        $scheduler.Connect()
        $rootFolder = $scheduler.GetFolder("\")

        # Remove leading/trailing backslashes and split
        $relativePath = $TaskPath.Trim("\")
        if ([string]::IsNullOrWhiteSpace($relativePath)) {
            return
        }

        $segments = $relativePath -split '\\'
        $currentPath = "\"

        foreach ($segment in $segments) {
            if ([string]::IsNullOrWhiteSpace($segment)) {
                continue
            }

            $nextPath = if ($currentPath -eq "\") { "\$segment" } else { "$currentPath\$segment" }

            try {
                $null = $scheduler.GetFolder($nextPath)
                Write-Verbose "Task folder '$nextPath' already exists."
            }
            catch {
                try {
                    $parentFolder = $scheduler.GetFolder($currentPath)
                    $null = $parentFolder.CreateFolder($segment)
                    Write-Verbose "Created task folder '$nextPath'."
                }
                catch {
                    Write-Warning "Could not create task folder '$nextPath': $($_.Exception.Message)"
                }
            }

            $currentPath = $nextPath
        }
    }
    catch {
        Write-Warning "Error ensuring task folder exists: $($_.Exception.Message)"
    }
    finally {
        if ($null -ne $scheduler) {
            [System.Runtime.InteropServices.Marshal]::ReleaseComObject($scheduler) | Out-Null
        }
    }
}

function New-AdminRemovalTask {
    <#
    .SYNOPSIS
        Creates a scheduled task to remove a user from the Administrators group.

    .DESCRIPTION
        Creates a hidden scheduled task that runs as SYSTEM at the specified time
        to remove the user from the local Administrators group. The task includes
        retry logic and self-cleanup.

    .PARAMETER Username
        The username to remove from the Administrators group.

    .PARAMETER ExecuteAt
        The datetime when the removal task should execute.

    .PARAMETER TaskPath
        The path in Task Scheduler where the task will be created.
        Defaults to \Microsoft\Windows\MakeMeAdminCLI.

    .OUTPUTS
        PSCustomObject with TaskName, TaskPath, ExecuteAt, and Success properties.

    .EXAMPLE
        $task = New-AdminRemovalTask -Username "DOMAIN\JohnDoe" -ExecuteAt (Get-Date).AddMinutes(15)
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [string]$Username,

        [Parameter(Mandatory)]
        [datetime]$ExecuteAt,

        [string]$TaskPath
    )

    # Get task path from config if not specified
    if (-not $TaskPath) {
        try {
            $configPath = Join-Path (Split-Path -Parent $PSScriptRoot) "config.json"
            if (Test-Path $configPath) {
                $config = Get-Content -Path $configPath -Raw | ConvertFrom-Json
                $TaskPath = $config.TaskPath
            }
        }
        catch { }
        if (-not $TaskPath) {
            $TaskPath = $script:DefaultTaskPath
        }
    }

    # Generate a unique task name based on the username
    $sanitizedUser = $Username -replace '[\\/:*?"<>|]', '_'
    $taskName = "RemoveAdmin_$sanitizedUser_$(Get-Date -Format 'yyyyMMddHHmmss')"

    $result = [PSCustomObject]@{
        TaskName = $taskName
        TaskPath = $TaskPath
        FullPath = "$TaskPath\$taskName"
        ExecuteAt = $ExecuteAt
        Username = $Username
        Success = $false
        Message = ""
    }

    try {
        # Ensure the task folder exists
        Ensure-TaskFolderExists -TaskPath $TaskPath

        # Build the removal script content
        $removalScript = Build-RemovalScript -Username $Username -TaskName $taskName -TaskPath $TaskPath

        # Create a temporary script file
        $scriptFolder = Join-Path $env:ProgramData "MakeMeAdminCLI\Scripts"
        if (-not (Test-Path $scriptFolder)) {
            New-Item -ItemType Directory -Path $scriptFolder -Force | Out-Null
        }
        $scriptPath = Join-Path $scriptFolder "$taskName.ps1"

        if ($PSCmdlet.ShouldProcess($scriptPath, "Create removal script")) {
            Set-Content -Path $scriptPath -Value $removalScript -Encoding UTF8 -Force
        }

        # Create the scheduled task
        if ($PSCmdlet.ShouldProcess($taskName, "Create scheduled task")) {
            $action = New-ScheduledTaskAction -Execute $script:PowerShellExe `
                -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`""

            $trigger = New-ScheduledTaskTrigger -Once -At $ExecuteAt

            $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest -LogonType ServiceAccount

            $settings = New-ScheduledTaskSettingsSet `
                -StartWhenAvailable `
                -AllowStartIfOnBatteries `
                -DontStopIfGoingOnBatteries `
                -Compatibility Win8 `
                -MultipleInstances IgnoreNew `
                -ExecutionTimeLimit (New-TimeSpan -Minutes 30)

            $task = New-ScheduledTask -Action $action -Principal $principal -Trigger $trigger -Settings $settings

            Register-ScheduledTask -TaskName $taskName -TaskPath $TaskPath -InputObject $task -Force | Out-Null

            # Try to hide the task (best effort)
            try {
                $taskObj = Get-ScheduledTask -TaskPath $TaskPath -TaskName $taskName -ErrorAction Stop
                if ($taskObj) {
                    $taskObj.Settings.Hidden = $true
                    Set-ScheduledTask -InputObject $taskObj | Out-Null
                    Write-Verbose "Task hidden successfully."
                }
            }
            catch {
                Write-Verbose "Could not set task hidden flag: $($_.Exception.Message)"
            }

            $result.Success = $true
            $result.Message = "Scheduled task '$taskName' created successfully. Removal scheduled for $($ExecuteAt.ToString('yyyy-MM-dd HH:mm:ss'))."
            Write-Verbose $result.Message
        }
    }
    catch {
        $result.Success = $false
        $result.Message = "Failed to create scheduled task: $($_.Exception.Message)"
        Write-Error $result.Message
    }

    return $result
}

function Build-RemovalScript {
    <#
    .SYNOPSIS
        Builds the PowerShell script content for the removal task.

    .DESCRIPTION
        Creates a self-contained PowerShell script that removes a user from
        the Administrators group, includes retry logic, and cleans up after itself.

    .PARAMETER Username
        The username to remove.

    .PARAMETER TaskName
        The name of the scheduled task (for self-cleanup).

    .PARAMETER TaskPath
        The task path (for self-cleanup).

    .OUTPUTS
        String containing the full script content.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$Username,

        [Parameter(Mandatory)]
        [string]$TaskName,

        [string]$TaskPath = $script:DefaultTaskPath
    )

    # Get the admin group SID constant
    $adminGroupSID = "S-1-5-32-544"

    $script = @"
#Requires -Version 5.1
# MakeMeAdminCLI - Admin Rights Removal Script
# User: $Username
# Task: $TaskName
# Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')

# Relaunch in 64-bit PowerShell if currently in 32-bit
if (-not [Environment]::Is64BitProcess) {
    `$ps64 = "`$env:WINDIR\SysNative\WindowsPowerShell\v1.0\powershell.exe"
    if (Test-Path `$ps64) {
        & `$ps64 -NoProfile -ExecutionPolicy Bypass -File `$PSCommandPath @args
        exit `$LASTEXITCODE
    }
}

`$ErrorActionPreference = 'Continue'
`$Username = '$Username'
`$TaskName = '$TaskName'
`$TaskPath = '$TaskPath'
`$AdminGroupSID = '$adminGroupSID'
`$EventSource = 'MakeMeAdminCLI'
`$MaxRetries = 3
`$RetryDelaySeconds = 30

# Resolve the Administrators group name from SID
function Get-AdminGroupName {
    try {
        `$sid = New-Object System.Security.Principal.SecurityIdentifier(`$AdminGroupSID)
        `$account = `$sid.Translate([System.Security.Principal.NTAccount])
        `$fullName = `$account.Value
        if (`$fullName -match '\\(.+)`$') {
            return `$Matches[1]
        }
        return `$fullName
    }
    catch {
        return 'Administrators' # Fallback
    }
}

# Check if user is still a member
function Test-IsMember {
    param([string]`$User, [string]`$Group)
    try {
        `$members = Get-LocalGroupMember -Group `$Group -ErrorAction SilentlyContinue
        foreach (`$member in `$members) {
            if (`$member.Name -eq `$User) { return `$true }
            # Also check just the username part
            if (`$member.Name -match '\\(.+)`$' -and `$User -match '\\(.+)`$') {
                if (`$Matches[1] -eq (`$User -replace '^[^\\]+\\','')) { return `$true }
            }
        }
        return `$false
    }
    catch { return `$false }
}

# Write to event log
function Write-EventLogSafe {
    param([string]`$Message, [int]`$EventId = 1006, [string]`$EntryType = 'Information')
    try {
        `$source = `$EventSource
        if (-not [System.Diagnostics.EventLog]::SourceExists(`$source)) {
            `$source = 'Application'
        }
        Write-EventLog -LogName Application -Source `$source -EventId `$EventId -EntryType `$EntryType -Message `$Message
    }
    catch { }
}

# Main removal logic
`$groupName = Get-AdminGroupName
`$removed = `$false
`$retryCount = 0

while (-not `$removed -and `$retryCount -lt `$MaxRetries) {
    `$retryCount++

    # Check if user is a member before trying to remove
    if (-not (Test-IsMember -User `$Username -Group `$groupName)) {
        `$removed = `$true
        Write-EventLogSafe -Message "User '`$Username' is not a member of '`$groupName'. No removal needed." -EventId 1006
        break
    }

    # Try Remove-LocalGroupMember first
    try {
        Remove-LocalGroupMember -Group `$groupName -Member `$Username -ErrorAction Stop
        Start-Sleep -Milliseconds 500
        if (-not (Test-IsMember -User `$Username -Group `$groupName)) {
            `$removed = `$true
            Write-EventLogSafe -Message "Successfully removed '`$Username' from '`$groupName' (attempt `$retryCount)." -EventId 1006
        }
    }
    catch {
        # Fallback to net localgroup
        try {
            `$null = & net localgroup "`$groupName" "`$Username" /delete 2>&1
            Start-Sleep -Milliseconds 500
            if (-not (Test-IsMember -User `$Username -Group `$groupName)) {
                `$removed = `$true
                Write-EventLogSafe -Message "Successfully removed '`$Username' from '`$groupName' via net localgroup (attempt `$retryCount)." -EventId 1006
            }
        }
        catch { }
    }

    if (-not `$removed -and `$retryCount -lt `$MaxRetries) {
        Start-Sleep -Seconds `$RetryDelaySeconds
    }
}

if (-not `$removed) {
    Write-EventLogSafe -Message "WARNING: Failed to remove '`$Username' from '`$groupName' after `$MaxRetries attempts." -EventId 1010 -EntryType Warning
}

# Update state file
try {
    `$stateFilePath = Join-Path `$env:ProgramData "MakeMeAdminCLI\state.json"
    if (Test-Path `$stateFilePath) {
        `$state = Get-Content -Path `$stateFilePath -Raw | ConvertFrom-Json
        `$updatedUsers = @()
        foreach (`$user in `$state.ActiveUsers) {
            if (`$user.Username -ne `$Username) {
                `$updatedUsers += `$user
            }
        }
        `$state.ActiveUsers = `$updatedUsers
        `$state.LastUpdated = (Get-Date).ToString('o')
        `$state | ConvertTo-Json -Depth 10 | Set-Content -Path `$stateFilePath -Encoding UTF8 -Force
    }
}
catch { }

# Self-cleanup: unregister this task
try {
    Unregister-ScheduledTask -TaskName `$TaskName -TaskPath `$TaskPath -Confirm:`$false -ErrorAction SilentlyContinue
}
catch { }

# Remove this script file
try {
    `$scriptFolder = Join-Path `$env:ProgramData "MakeMeAdminCLI\Scripts"
    `$scriptPath = Join-Path `$scriptFolder "`$TaskName.ps1"
    if (Test-Path `$scriptPath) {
        Remove-Item -Path `$scriptPath -Force -ErrorAction SilentlyContinue
    }
}
catch { }

exit 0
"@


    return $script
}

function Remove-AdminRemovalTask {
    <#
    .SYNOPSIS
        Removes a scheduled admin removal task.

    .DESCRIPTION
        Unregisters a scheduled task and removes its associated script file.

    .PARAMETER TaskName
        The name of the task to remove.

    .PARAMETER TaskPath
        The path of the task. Defaults to \Microsoft\Windows\MakeMeAdminCLI.

    .OUTPUTS
        Boolean indicating success.

    .EXAMPLE
        Remove-AdminRemovalTask -TaskName "RemoveAdmin_DOMAIN_JohnDoe_20240115120000"
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [string]$TaskName,

        [string]$TaskPath = $script:DefaultTaskPath
    )

    try {
        if ($PSCmdlet.ShouldProcess($TaskName, "Remove scheduled task")) {
            # Remove the scheduled task
            Unregister-ScheduledTask -TaskName $TaskName -TaskPath $TaskPath -Confirm:$false -ErrorAction SilentlyContinue

            # Remove the associated script file
            $scriptFolder = Join-Path $env:ProgramData "MakeMeAdminCLI\Scripts"
            $scriptPath = Join-Path $scriptFolder "$TaskName.ps1"
            if (Test-Path $scriptPath) {
                Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue
            }

            Write-Verbose "Task '$TaskName' removed successfully."
            return $true
        }
        return $false
    }
    catch {
        Write-Warning "Failed to remove task '$TaskName': $($_.Exception.Message)"
        return $false
    }
}

function Get-AdminRemovalTasks {
    <#
    .SYNOPSIS
        Gets all pending admin removal tasks.

    .DESCRIPTION
        Returns a list of all scheduled tasks in the MakeMeAdminCLI folder.

    .PARAMETER TaskPath
        The task path to query. Defaults to \Microsoft\Windows\MakeMeAdminCLI.

    .OUTPUTS
        Array of scheduled task objects.

    .EXAMPLE
        $tasks = Get-AdminRemovalTasks
        $tasks | Format-Table TaskName, State, NextRunTime
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [string]$TaskPath = $script:DefaultTaskPath
    )

    try {
        $tasks = Get-ScheduledTask -TaskPath "$TaskPath\" -ErrorAction SilentlyContinue

        $result = @()
        foreach ($task in $tasks) {
            $taskInfo = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction SilentlyContinue
            $result += [PSCustomObject]@{
                TaskName = $task.TaskName
                TaskPath = $task.TaskPath
                State = $task.State
                NextRunTime = $taskInfo.NextRunTime
                LastRunTime = $taskInfo.LastRunTime
                LastTaskResult = $taskInfo.LastTaskResult
            }
        }

        return $result
    }
    catch {
        Write-Verbose "Failed to get tasks from '$TaskPath': $($_.Exception.Message)"
        return @()
    }
}

function Remove-AllAdminRemovalTasks {
    <#
    .SYNOPSIS
        Removes all pending admin removal tasks.

    .DESCRIPTION
        Unregisters all scheduled tasks in the MakeMeAdminCLI folder
        and removes their associated script files.

    .PARAMETER TaskPath
        The task path to clean. Defaults to \Microsoft\Windows\MakeMeAdminCLI.

    .EXAMPLE
        Remove-AllAdminRemovalTasks
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [string]$TaskPath = $script:DefaultTaskPath
    )

    $tasks = Get-AdminRemovalTasks -TaskPath $TaskPath

    foreach ($task in $tasks) {
        if ($PSCmdlet.ShouldProcess($task.TaskName, "Remove scheduled task")) {
            Remove-AdminRemovalTask -TaskName $task.TaskName -TaskPath $TaskPath
        }
    }

    # Clean up script folder
    $scriptFolder = Join-Path $env:ProgramData "MakeMeAdminCLI\Scripts"
    if (Test-Path $scriptFolder) {
        if ($PSCmdlet.ShouldProcess($scriptFolder, "Clean up script folder")) {
            Get-ChildItem -Path $scriptFolder -Filter "RemoveAdmin_*.ps1" | Remove-Item -Force -ErrorAction SilentlyContinue
        }
    }
}

# Export module members (when dot-sourced from module)
if ($MyInvocation.MyCommand.ScriptBlock.Module) {
    Export-ModuleMember -Function @(
        'Ensure-TaskFolderExists',
        'New-AdminRemovalTask',
        'Remove-AdminRemovalTask',
        'Get-AdminRemovalTasks',
        'Remove-AllAdminRemovalTasks'
    )
}