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。
Import-Module ActiveDirectory
$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"
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)
$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 " | "
}
}
$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」。