iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
IT Operation

系統工程師的 30 天自動化維運實戰:PowerShell × AD × Windows Server系列 第 19 篇

Day 19|AD 批次停用離職帳號:從 Offboarding CSV 到安全的 Disable Workflow

  • 分享至 

  • xImage
  •  

Day 18 我們處理的是:
新人報到
↓
NewUsers.csv
↓
Validation
↓
建立 Disabled User
↓
加入 Group
↓
Verification
↓
最後才 Enable

今天要處理帳號生命週期的另一端:
Offboarding
也就是員工:
離職
調離
帳號終止
存取權限撤銷

企業環境裡,離職帳號通常不是單純:
Disable-ADAccount

就結束。
實際上可能還要處理:
停用帳號
移除 VPN 權限
移除管理權限
更新 Description
搬到 Disabled OU
保留原本 Group Membership
留下 Ticket
產生 Change Log
確認最後狀態

更麻煩的是:
如果停用成功,但移動 OU 失敗,這次 Automation 到底算成功還是失敗?

所以 Day 19 的重點不只是:
Disable-ADAccount

而是開始處理真正 Automation 很重要的概念:
Partial Failure
今天要把流程做成:
Offboarding.csv
↓
Validation
↓
Before-State Snapshot
↓
Preview
↓
Disable Account
↓
Remove Sensitive Groups
↓
Update Description
↓
Move Disabled OU
↓
Verification
↓
Success / Partial / Failed
↓
Result Report

先建立 Offboarding.csv
例如:
SamAccountName,OffboardingDate,Ticket
user01,2026-09-27,INC001001
user02,2026-09-27,INC001002
user03,2026-09-30,INC001003

這份 CSV 只保存:
哪個帳號
哪一天離職
對應哪張 Ticket

不要放:
Password
管理員帳號
Token
其他 Secret

為什麼一定要有 Ticket?
Technically:
Disable-ADAccount

不需要 Ticket。
但是企業維運真正重要的是:
為什麼要做這個 Change?

假設三個月後有人問:
user01 是誰停用的?

如果 Log 只有:
user01 Disabled

資訊不夠。
比較有價值的是:
2026-09-27 06:00
User : user01
Action : Disable
Ticket : INC001001
Operator : admin01
Result : Success

這樣才有 Change Traceability。
今天先定義 Disabled OU
例如:
$DisabledOU =
"OU=DisabledUsers,DC=contoso,DC=com"

在正式執行之前一定先確認:
Get-ADOrganizationalUnit -Identity $DisabledOU
-ErrorAction Stop

如果:
Disabled OU 不存在

我會直接停止整批 Change。
因為這不是單一 User 資料錯誤。
而是:
整個 Automation Configuration 有問題。

定義要立即移除的 Sensitive Groups
今天不做:
離職
↓
所有 Group Membership 全部 Remove

而是明確定義:
$SensitiveGroups = @(
"VPN-Users"
"Server-Admins"
"FileServer-Admins"
)

這些代表公司規定:
離職時必須立即撤銷的高風險存取權限。

其他 Group 則可以先:
Snapshot
Review
Retention

再依政策處理。
為什麼不是全部 Remove?
因為 Group Membership 可能包含:
稽核用途
歷史資訊
Mail Group
Workflow Group
License Group
Application Group

甚至:
Domain Users

還牽涉 Primary Group。
所以第一版 Offboarding Automation 應該:
只改明確知道要改的東西。

而不是:
我猜全部都可以刪。

第一個步驟永遠是 Validation
先:
$Requests =
Import-Csv "C:\ADAutomation\Input\Offboarding.csv"

接著逐筆確認:
SamAccountName 有值嗎?
OffboardingDate 有值嗎?
Ticket 有值嗎?
User 存在嗎?
日期到了嗎?

例如:
foreach ($Request in $Requests) {

$Errors = @()

if (
    [string]::IsNullOrWhiteSpace(
        $Request.SamAccountName
    )
) {

    $Errors += "SamAccountName is empty"
}

if (
    [string]::IsNullOrWhiteSpace(
        $Request.Ticket
    )
) {

    $Errors += "Ticket is empty"
}

}

OffboardingDate 要先判斷
假設:
user03
OffboardingDate = 2026-09-30

今天還沒到日期。
那就不應該:
提前停用

可以:
$OffboardingDate =
[datetime]$Request.OffboardingDate

然後:
if ($OffboardingDate.Date -gt (Get-Date).Date) {

$Status = "Scheduled"

}

所以:
今天以前 / 今天
→ 可以進入 Change

未來日期
→ Scheduled

這可以降低提前停用帳號的風險。
CSV 日期格式最好固定:
yyyy-MM-dd

避免:
09/10/2026

到底是 9 月 10 日還是 10 月 9 日的問題。
User 不存在怎麼辦?
例如:
try {

$User = Get-ADUser `
    -Identity $Request.SamAccountName `
    -Properties `
        Enabled,
        Department,
        Title,
        Description,
        MemberOf,
        LastLogonDate `
    -ErrorAction Stop

}
catch {

# Record error

}

如果:
user01

查不到,
不要直接說:
已經離職完成。

因為也可能是:
CSV 帳號拼錯
查錯 Domain
DC 有問題
帳號已被人工刪除

所以狀態比較適合:
Failed Validation

或:
NotFound

Change 以前一定先做 Before-State Snapshot
這是今天非常重要的一步。
假設:
user01

目前:
Enabled = True
Department = IT
OU = Taipei\Users
Groups = 15 個

修改前先留下資料。
例如:
$BeforeState = [PSCustomObject]@{

SamAccountName =
    $User.SamAccountName

Name =
    $User.Name

Enabled =
    $User.Enabled

Department =
    $User.Department

Title =
    $User.Title

Description =
    $User.Description

LastLogonDate =
    $User.LastLogonDate

DistinguishedName =
    $User.DistinguishedName

Ticket =
    $Request.Ticket

SnapshotTime =
    Get-Date

}

再:
$BeforeState |
Export-Csv -Path "C:\ADAutomation\Reports\user01_Before.csv"
-NoTypeInformation `
-Encoding UTF8

Group Membership 也保存
User 原本在哪些 Group,離職前最好保留。
例如直接 Membership:
$DirectGroups = foreach (
$GroupDN in $User.MemberOf
) {

Get-ADGroup `
    -Identity $GroupDN |
Select-Object `
    Name,
    GroupCategory,
    GroupScope,
    DistinguishedName

}

然後:
$DirectGroups |
Export-Csv -Path "C:\ADAutomation\Reports\user01_Groups_Before.csv"
-NoTypeInformation `
-Encoding UTF8

以後如果要追:
user01 原本直接加入哪些 Group?

至少有紀錄。
為什麼這裡用 MemberOf?
因為今天我們真正要處理的是:
直接 Membership。

假設:
user01
↓
IT-Admins
↓
Server-Admins

user01 可能並沒有直接加入:
Server-Admins

所以不能直接:
Remove-ADGroupMember -Identity "Server-Admins"
-Members "user01"

期待 Nested Permission 自動消失。
真正要解除的是:
user01 → IT-Admins

這條直接關係。
所以 Day 16 學到的:
Direct Membership
Recursive Membership

現在就真的派上用場了。
Preview 還是不能省
Before-State 完成後,先:
Disable-ADAccount -Identity $User.SamAccountName
-WhatIf

Description:
Set-ADUser -Identity $User.SamAccountName
-Description "Disabled 2026-09-27 - INC001001" `
-WhatIf

Move OU:
Move-ADObject -Identity $User.DistinguishedName
-TargetPath $DisabledOU `
-WhatIf

Group:
Remove-ADGroupMember -Identity "VPN-Users"
-Members $User -Confirm:$false
-WhatIf

這時候:
No changes

只是預覽。
正式執行時,不能只有一個 try/catch
很容易寫:
try {

Disable-ADAccount ...

Remove-ADGroupMember ...

Set-ADUser ...

Move-ADObject ...

}
catch {

Write-Host "Failed"

}

問題是:
到底是哪一步失敗?

例如:
Disable → Success
Group → Success
Set → Success
Move → Failed

如果最後只記:
Failed

會讓人誤以為:
整個 Offboarding 都沒做。

但其實帳號早就 Disable 了。
所以今天開始要:
每一個 Change Step 都有自己的 Status。

定義 Step Status
例如:
DisableStatus
GroupCleanupStatus
DescriptionStatus
MoveOUStatus
VerificationStatus

最後才算:
OverallStatus

這就是 State Tracking。
第一個 Step:Disable Account
先給預設值:
$DisableStatus = "Pending"

然後:
try {

Disable-ADAccount `
    -Identity $User.SamAccountName `
    -ErrorAction Stop

$DisableStatus = "Success"

}
catch {

$DisableStatus = "Failed"

$Errors +=
    "Disable failed: $($_.Exception.Message)"

}

如果帳號本來就 Disabled 呢?
這是 Automation 很重要的情況。
假設昨天 Script:
Disable 成功
Move OU 失敗

今天重新跑。
如果看到:
Enabled = False

不能說:
Error:帳號已 Disabled

比較好的做法:
if (-not $User.Enabled) {

$DisableStatus =
    "AlreadyDisabled"

}
else {

# Disable account

}

這就是:
Idempotency
同一個 Workflow 重跑,不會因為前面某一步已完成就整個壞掉。
第二步:Sensitive Group Cleanup
首先取得 User 目前的 Direct Groups。
例如:
$CurrentDirectGroups = @(
$User.MemberOf |
ForEach-Object {

    (
        Get-ADGroup `
            -Identity $_
    ).Name
}

)

然後:
$GroupFailures = @()

逐個處理:
foreach ($GroupName in $SensitiveGroups) {

if (
    $CurrentDirectGroups -contains
    $GroupName
) {

    try {

        Remove-ADGroupMember `
            -Identity $GroupName `
            -Members $User.SamAccountName `
            -Confirm:$false `
            -ErrorAction Stop

    }
    catch {

        $GroupFailures +=
            "$GroupName : $($_.Exception.Message)"
    }
}

}

最後:
if ($GroupFailures.Count -eq 0) {

$GroupCleanupStatus =
    "Success"

}
else {

$GroupCleanupStatus =
    "Partial"

}

為什麼 Group Cleanup 可能是 Partial?
例如:
VPN-Users
→ Removed

Server-Admins
→ Removed

FileServer-Admins
→ Failed

那不能寫:
Success

也不能完全寫:
Failed

因為有兩項已經完成。
所以:
GroupCleanupStatus = Partial

比較準確。
第三步:更新 Description
例如:
$NewDescription =
"Disabled $(Get-Date -Format 'yyyy-MM-dd') - $($Request.Ticket)"

執行:
try {

Set-ADUser `
    -Identity $User.SamAccountName `
    -Description $NewDescription `
    -ErrorAction Stop

$DescriptionStatus =
    "Success"

}
catch {

$DescriptionStatus =
    "Failed"

$Errors +=
    "Description update failed: $($_.Exception.Message)"

}

第四步:Move Disabled OU
這裡還有一個細節。
假設今天 Script 重跑,
User 已經在:
OU=DisabledUsers

就不需要再 Move。
可以:
$CurrentUser =
Get-ADUser `
-Identity $User.SamAccountName

判斷:
if (
$CurrentUser.DistinguishedName `
-like "*,$DisabledOU"
) {

$MoveOUStatus =
    "AlreadyInTargetOU"

}

如果不在:
else {

try {

    Move-ADObject `
        -Identity $CurrentUser.DistinguishedName `
        -TargetPath $DisabledOU `
        -ErrorAction Stop

    $MoveOUStatus =
        "Success"

}
catch {

    $MoveOUStatus =
        "Failed"

    $Errors +=
        "Move OU failed: $($_.Exception.Message)"
}

}

這一樣是在做:
Idempotent Change。

為什麼 Move 前重新 Get-ADUser?
因為 AD Object 在流程中可能已經被修改。
而:
DistinguishedName

是位置相關資料。
正式 Change Script 裡,不要一直假設:
我 10 個步驟以前取得的 Object 狀態一定還是最新的。

需要最新 State 時:
Get-ADUser

重新取得會比較安全。
第五步:Verification
Change 全部做完後:
$VerifyUser =
Get-ADUser -Identity $User.SamAccountName
-Properties Enabled, Description, MemberOf
-ErrorAction Stop

我們想確認:
Enabled = False
Description 正確
已在 Disabled OU
Sensitive Groups 已清理

驗證 Enabled
$EnabledOK =
(-not $VerifyUser.Enabled)

驗證 OU
$OUOK =
$VerifyUser.DistinguishedName `
-like "*,$DisabledOU"

驗證 Sensitive Groups
重新取得:
$RemainingDirectGroups = @(
$VerifyUser.MemberOf |
ForEach-Object {

    (
        Get-ADGroup `
            -Identity $_
    ).Name
}

)

找:
$RemainingSensitiveGroups =
$SensitiveGroups |
Where-Object {
$_ -in $RemainingDirectGroups
}

如果:
$RemainingSensitiveGroups.Count -eq 0

代表這部分 OK。
Verification Status
例如:
if (
$EnabledOK -and
$OUOK -and
$RemainingSensitiveGroups.Count -eq 0
) {

$VerificationStatus =
    "Success"

}
else {

$VerificationStatus =
    "Failed"

}

這樣我們不是只相信 Cmdlet 沒跳 Error。
而是真的重新查 AD。
最重要的問題:Overall Status 怎麼算?
假設:
Disable = Success
Group Cleanup = Success
Description = Success
Move OU = Success
Verification = Success

很簡單:
Overall = Success

如果:
Disable = Success
Group Cleanup = Partial
Description = Success
Move OU = Success
Verification = Failed

那:
Overall = Partial

如果:
Disable = Failed

那主目標:
終止帳號登入。

都沒有完成。
我會把:
Overall = Failed

而且通常不應繼續假裝整個 Offboarding 完成。
可以定義這樣的規則
if (
$DisableStatus -eq "Failed"
) {

$OverallStatus =
    "Failed"

}
elseif (
$GroupCleanupStatus -eq "Partial" -or
$GroupCleanupStatus -eq "Failed" -or
$DescriptionStatus -eq "Failed" -or
$MoveOUStatus -eq "Failed" -or
$VerificationStatus -eq "Failed"
) {

$OverallStatus =
    "Partial"

}
else {

$OverallStatus =
    "Success"

}

這就是今天很重要的:
不要只問 Script 有沒有跑完,而要知道 Workflow 最後完成到哪裡。

Script 跑完不代表 Change 成功
假設看到:
Script Completed

但實際:
100 Accounts

Success = 91
Partial = 6
Failed = 3

這才是真正的結果。
所以最後一定要:
Summary

建立 Result Object
例如:
$Result = [PSCustomObject]@{

SamAccountName =
    $User.SamAccountName

Ticket =
    $Request.Ticket

OffboardingDate =
    $Request.OffboardingDate

DisableStatus =
    $DisableStatus

GroupCleanupStatus =
    $GroupCleanupStatus

DescriptionStatus =
    $DescriptionStatus

MoveOUStatus =
    $MoveOUStatus

VerificationStatus =
    $VerificationStatus

OverallStatus =
    $OverallStatus

RemainingSensitiveGroups =
    $RemainingSensitiveGroups -join ";"

ErrorMessage =
    $Errors -join " | "

CheckTime =
    Get-Date

}

結果可能:
User Disable Group Move Verify Overall
user01 Success Success Success Success Success
user02 Success Partial Success Failed Partial
user03 Failed NotStarted NotStarted Failed Failed

這比:
user01 OK
user02 NG
user03 NG

有價值得多。
今天完整 Preview Script
第一支仍然建議:
Preview only。

==========================================

AD Bulk Offboarding Preview

Day 19

==========================================

Import-Module ActiveDirectory

==========================================

Configuration

==========================================

$InputFile =
"C:\ADAutomation\Input\Offboarding.csv"

$ReportFolder =
"C:\ADAutomation\Reports"

$DisabledOU =
"OU=DisabledUsers,DC=contoso,DC=com"

$SensitiveGroups = @(
"VPN-Users"
"Server-Admins"
"FileServer-Admins"
)

$Date =
Get-Date -Format "yyyyMMdd"

==========================================

Validate Environment

==========================================

if (-not (Test-Path $InputFile)) {

Write-Error "Offboarding CSV not found."

return

}

try {

Get-ADOrganizationalUnit `
    -Identity $DisabledOU `
    -ErrorAction Stop |
    Out-Null

}
catch {

Write-Error `
    "Disabled OU not found: $DisabledOU"

return

}

$Requests =
@(Import-Csv $InputFile)

==========================================

Preview Results

==========================================

$PreviewResults = @()

foreach ($Request in $Requests) {

$Errors = @()

$User = $null


# --------------------------------------
# Basic Validation
# --------------------------------------

if (
    [string]::IsNullOrWhiteSpace(
        $Request.SamAccountName
    )
) {

    $Errors +=
        "SamAccountName is empty"
}


if (
    [string]::IsNullOrWhiteSpace(
        $Request.Ticket
    )
) {

    $Errors +=
        "Ticket is empty"
}


try {

    $OffboardingDate =
        [datetime]$Request.OffboardingDate

}
catch {

    $Errors +=
        "Invalid OffboardingDate"
}


# --------------------------------------
# Query User
# --------------------------------------

if ($Errors.Count -eq 0) {

    try {

        $User = Get-ADUser `
            -Identity $Request.SamAccountName `
            -Properties `
                Enabled,
                Department,
                Title,
                Description,
                MemberOf,
                LastLogonDate `
            -ErrorAction Stop

    }
    catch {

        $Errors +=
            "User not found or query failed"
    }
}


# --------------------------------------
# Future Date
# --------------------------------------

$Action =
    "Offboard"


if (
    $null -ne $OffboardingDate -and
    $OffboardingDate.Date -gt
    (Get-Date).Date
) {

    $Action =
        "Scheduled"
}


# --------------------------------------
# Sensitive Group Review
# --------------------------------------

$SensitiveMemberships = @()


if ($null -ne $User) {

    $DirectGroupNames = @(
        $User.MemberOf |
        ForEach-Object {

            (
                Get-ADGroup `
                    -Identity $_
            ).Name
        }
    )


    $SensitiveMemberships =
        $SensitiveGroups |
        Where-Object {
            $_ -in $DirectGroupNames
        }
}


# --------------------------------------
# Result
# --------------------------------------

$PreviewResults +=
    [PSCustomObject]@{

        SamAccountName =
            $Request.SamAccountName

        Name =
            if ($User) {
                $User.Name
            }
            else {
                ""
            }

        CurrentEnabled =
            if ($User) {
                $User.Enabled
            }
            else {
                $null
            }

        OffboardingDate =
            $Request.OffboardingDate

        Ticket =
            $Request.Ticket

        SensitiveGroups =
            $SensitiveMemberships -join ";"

        PlannedAction =
            $Action

        Ready =
            (
                $Errors.Count -eq 0 -and
                $Action -eq "Offboard"
            )

        ValidationErrors =
            $Errors -join " | "
    }

}

==========================================

Export

==========================================

$PreviewFile =
"$ReportFolder\Offboarding_Preview_$Date.csv"

$PreviewResults |
Export-Csv -Path $PreviewFile
-NoTypeInformation `
-Encoding UTF8

Write-Host ""
Write-Host "===== OFFBOARDING PREVIEW ====="
Write-Host ""

$PreviewResults |
Format-Table SamAccountName, CurrentEnabled, Ticket, PlannedAction, Ready, ValidationErrors
-AutoSize

Write-Host ""
Write-Host "No Active Directory changes were made."

Preview 可能得到
SamAccountName Enabled Ticket Action Ready


user01 True INC001001 Offboard True
user02 False INC001002 Offboard True
user03 True INC001003 Scheduled False
baduser INC001004 Offboard False

這裡很有意思。
user02:
Already Disabled

不代表要直接 Skip。
因為:
Sensitive Group
Move OU
Description

可能還沒有完成。
所以可以繼續把它當:
Incomplete Offboarding。

這就是 Idempotency 的價值。
正式 Execution 的核心版本
下面這段展示正式 Workflow 的主要結構。
正式環境仍應經過變更核准,並先在測試環境驗證。
$Results = @()

foreach ($Request in $ApprovedRequests) {

$Errors = @()

$DisableStatus =
    "NotStarted"

$GroupCleanupStatus =
    "NotStarted"

$DescriptionStatus =
    "NotStarted"

$MoveOUStatus =
    "NotStarted"

$VerificationStatus =
    "NotStarted"


try {

    # ==================================
    # Query Current User
    # ==================================

    $User = Get-ADUser `
        -Identity $Request.SamAccountName `
        -Properties `
            Enabled,
            MemberOf,
            Department,
            Title,
            Description,
            LastLogonDate `
        -ErrorAction Stop


    # ==================================
    # Disable Account
    # ==================================

    if (-not $User.Enabled) {

        $DisableStatus =
            "AlreadyDisabled"

    }
    else {

        try {

            Disable-ADAccount `
                -Identity $User.SamAccountName `
                -ErrorAction Stop

            $DisableStatus =
                "Success"

        }
        catch {

            $DisableStatus =
                "Failed"

            throw
        }
    }


    # ==================================
    # Sensitive Group Cleanup
    # ==================================

    $GroupFailures = @()


    $DirectGroupNames = @(
        $User.MemberOf |
        ForEach-Object {

            (
                Get-ADGroup `
                    -Identity $_
            ).Name
        }
    )


    foreach ($GroupName in $SensitiveGroups) {

        if (
            $GroupName -in
            $DirectGroupNames
        ) {

            try {

                Remove-ADGroupMember `
                    -Identity $GroupName `
                    -Members $User.SamAccountName `
                    -Confirm:$false `
                    -ErrorAction Stop

            }
            catch {

                $GroupFailures +=
                    "$GroupName : $($_.Exception.Message)"
            }
        }
    }


    if ($GroupFailures.Count -eq 0) {

        $GroupCleanupStatus =
            "Success"

    }
    else {

        $GroupCleanupStatus =
            "Partial"

        $Errors +=
            $GroupFailures
    }


    # ==================================
    # Description
    # ==================================

    try {

        $Description =
            "Disabled $(Get-Date -Format 'yyyy-MM-dd') - $($Request.Ticket)"


        Set-ADUser `
            -Identity $User.SamAccountName `
            -Description $Description `
            -ErrorAction Stop


        $DescriptionStatus =
            "Success"

    }
    catch {

        $DescriptionStatus =
            "Failed"

        $Errors +=
            "Description: $($_.Exception.Message)"
    }


    # ==================================
    # Move OU
    # ==================================

    $CurrentUser =
        Get-ADUser `
            -Identity $User.SamAccountName


    if (
        $CurrentUser.DistinguishedName `
        -like "*,$DisabledOU"
    ) {

        $MoveOUStatus =
            "AlreadyInTargetOU"

    }
    else {

        try {

            Move-ADObject `
                -Identity $CurrentUser.DistinguishedName `
                -TargetPath $DisabledOU `
                -ErrorAction Stop


            $MoveOUStatus =
                "Success"

        }
        catch {

            $MoveOUStatus =
                "Failed"

            $Errors +=
                "Move OU: $($_.Exception.Message)"
        }
    }


    # ==================================
    # Verification
    # ==================================

    $VerifyUser =
        Get-ADUser `
            -Identity $User.SamAccountName `
            -Properties `
                Enabled,
                MemberOf `
            -ErrorAction Stop


    $RemainingGroups = @(
        $VerifyUser.MemberOf |
        ForEach-Object {

            (
                Get-ADGroup `
                    -Identity $_
            ).Name
        }
    )


    $RemainingSensitiveGroups =
        @(
            $SensitiveGroups |
            Where-Object {
                $_ -in $RemainingGroups
            }
        )


    $EnabledOK =
        (-not $VerifyUser.Enabled)


    $OUOK =
        $VerifyUser.DistinguishedName `
        -like "*,$DisabledOU"


    if (
        $EnabledOK -and
        $OUOK -and
        $RemainingSensitiveGroups.Count -eq 0
    ) {

        $VerificationStatus =
            "Success"

    }
    else {

        $VerificationStatus =
            "Failed"

        $Errors +=
            "Post-change verification failed"
    }


    # ==================================
    # Overall
    # ==================================

    if (
        $DisableStatus -eq "Failed"
    ) {

        $OverallStatus =
            "Failed"

    }
    elseif (
        $GroupCleanupStatus -eq "Partial" -or
        $DescriptionStatus -eq "Failed" -or
        $MoveOUStatus -eq "Failed" -or
        $VerificationStatus -eq "Failed"
    ) {

        $OverallStatus =
            "Partial"

    }
    else {

        $OverallStatus =
            "Success"
    }

}
catch {

    if ($DisableStatus -eq "NotStarted") {

        $DisableStatus =
            "Failed"
    }


    $OverallStatus =
        "Failed"

    $Errors +=
        $_.Exception.Message
}


# ==================================
# Result
# ==================================

$Results +=
    [PSCustomObject]@{

        SamAccountName =
            $Request.SamAccountName

        Ticket =
            $Request.Ticket

        DisableStatus =
            $DisableStatus

        GroupCleanupStatus =
            $GroupCleanupStatus

        DescriptionStatus =
            $DescriptionStatus

        MoveOUStatus =
            $MoveOUStatus

        VerificationStatus =
            $VerificationStatus

        OverallStatus =
            $OverallStatus

        ErrorMessage =
            $Errors -join " | "

        CheckTime =
            Get-Date
    }

}

最後輸出 Change Result
$Date =
Get-Date -Format "yyyyMMdd"

$Results |
Export-Csv -Path "C:\ADAutomation\Reports\Offboarding_Result_$Date.csv"
-NoTypeInformation `
-Encoding UTF8

最後可能:
user01 → Success
user02 → Partial
user03 → Success
user04 → Failed

再做一份 Summary
$Summary = [PSCustomObject]@{

CheckTime =
    Get-Date

Total =
    @($Results).Count

Success =
    @(
        $Results |
        Where-Object OverallStatus -eq "Success"
    ).Count

Partial =
    @(
        $Results |
        Where-Object OverallStatus -eq "Partial"
    ).Count

Failed =
    @(
        $Results |
        Where-Object OverallStatus -eq "Failed"
    ).Count

}

例如:
Total : 100
Success : 94
Partial : 4
Failed : 2

這才是:
這次 Offboarding Batch 真正的執行結果。

Partial 要比 Success 更優先看
假設:
Success = 94
Partial = 4
Failed = 2

一般人可能先看 Failed。
但我認為:
Partial

也很重要。
因為 Partial 代表:
系統已經被改了一部分。

例如:
user02

Disabled ✓
VPN Removed ✓
Move OU ✗

它不像 Failed:
什麼都沒做成功

而是處於:
Intermediate State

這種狀態最容易被忘記。
不要自動 Rollback 所有東西
假設:
Disable Account
→ Success

Remove VPN
→ Success

Move OU
→ Failed

不要看到 Move 失敗就自動:
Enable User
把 VPN 加回去

因為 Offboarding 的核心安全目標是:
撤銷離職者存取權限。

自動 Rollback 反而可能重新開放存取。
這就是為什麼 Change Automation 的 Rollback 不能只是:
做過什麼就反著做一次。

需要考慮:
Business Intent
Security Impact
Current State

Offboarding 裡 Disable 是比較重要的 Critical Step
如果:
Move OU Failed

帳號仍然 Disabled。
安全核心可能已經完成,只是治理工作沒完成。
所以:
Partial

合理。
但是:
Disable Failed

代表:
User 可能仍然可以登入。

這種情況應該:
Failed
+
高優先處理

這就是 Workflow 裡不同 Step 有不同重要性的概念。
可以開始加入 Critical / Non-Critical Step
例如:
Disable Account
→ Critical

Sensitive Group Cleanup
→ Critical

Move OU
→ Administrative

Description
→ Administrative

以後甚至可以讓 Script:
Critical Step Failed
↓
立即提高 Alert

Administrative Step Failed
↓
Partial / Follow-up

這會比所有 Error 都當成相同嚴重度更成熟。
Day 19 的專案結構
目前可以整理:
ADAutomation/
│
├── Input/
│ ├── NewUsers.csv
│ └── Offboarding.csv
│
├── Scripts/
│ ├── Preview-Onboarding.ps1
│ ├── Invoke-Onboarding.ps1
│ ├── Preview-Offboarding.ps1
│ └── Invoke-Offboarding.ps1
│
├── Reports/
│ ├── Onboarding_Preview.csv
│ ├── Onboarding_Result.csv
│ ├── Offboarding_Preview.csv
│ ├── Offboarding_Result.csv
│ └── user01_Before.csv
│
└── Logs/

現在已經開始形成真正的:
AD Lifecycle Automation Toolkit

Day 19 小結
今天我們把 Day 17 的單一帳號 Offboarding,正式擴充成:
CSV-driven Bulk Offboarding Workflow

但真正重要的並不是:
Disable-ADAccount

而是我們第一次開始認真處理:
Partial Failure
也就是:
Step A Success
Step B Success
Step C Failed
Step D Success

不能簡單說:
整個工作成功

也不能說:
什麼都沒做

所以我們開始記錄:
DisableStatus
GroupCleanupStatus
DescriptionStatus
MoveOUStatus
VerificationStatus
OverallStatus

並建立:
Success
Partial
Failed
Scheduled
AlreadyDisabled
AlreadyInTargetOU

這種更接近實際 Automation 的狀態。
另外今天也再次強調三個原則。
第一個:Change 前一定保存 Before-State。
帳號狀態
Group Membership
OU
Ticket

都可以成為之後的稽核證據。
第二個:重跑 Automation 不應該把已完成的步驟當成 Error。
AlreadyDisabled
AlreadyInTargetOU

其實是:
目前狀態已經符合 Desired State。

這就是 Idempotency。
第三個:Disable 失敗與 Move OU 失敗,嚴重程度並不相同。
Automation 不只是:
執行 Command。

而要開始知道:
哪個 Step 是業務核心,哪個 Step 只是後續治理。

到了 Day 19,我們其實已經從:
PowerShell Script

慢慢走到:
State-aware Automation Workflow

這會是後面進入排程、通知與完整工具化非常重要的基礎。
Day 20 預告
Day 20|讓 PowerShell 自己每天跑:Task Scheduler 自動排程 Server 與 AD 巡檢
目前我們已經可以:
Server Health Check
AD User Audit
AD Computer Audit
AD Group Audit
Onboarding
Offboarding

但還有一個問題:
每次都要人工打開 PowerShell 執行,真的算自動化嗎?

所以 Day 20 我們會正式進入:
Task Scheduler

把:
ServerHealthCheck.ps1
ADInactiveUserReport.ps1
ADInactiveComputerReport.ps1

設定成:
每天 06:00
↓
自動執行
↓
寫 Log
↓
產生 Report
↓
成功 / 失敗留下 Exit Code

也會開始處理真正排程後很常遇到的問題:
手動跑成功
Task Scheduler 卻失敗

工作目錄不一樣
權限不同
PowerShell Execution Policy
網路磁碟看不到
憑證 / Service Account
Exit Code 到底代表什麼

Day 20 開始,我們會把「會跑的 Script」正式變成「沒有人在電腦前也會自己執行的 Automation」。


上一篇
Day 18|AD 批次建立帳號:從 CSV Onboarding 到自動建立 User
下一篇
Day 20|讓 PowerShell 自己每天跑:Task Scheduler 自動排程 Server 與 AD 巡檢
系列文
系統工程師的 30 天自動化維運實戰:PowerShell × AD × Windows Server 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言