iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
IT Operation

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

Day 18|AD 批次建立帳號:從 CSV Onboarding 到自動建立 User

  • 分享至 

  • xImage
  •  

Day 17 我們第一次從:
Query / Audit / Report

走進:
Change Automation

也碰到了:
New-ADUser
Set-ADUser
Disable-ADAccount
Move-ADObject
Add-ADGroupMember

但昨天只處理單一 User。
真正進到企業環境,新人建立帳號往往不是:
今天建立 1 個 User

而可能是:
新人報到日
↓
20 個 User

組織擴編
↓
50 個 User

大量工讀 / 約聘
↓
100 個 User

如果每個人都:
打開 ADUC
↓
New User
↓
填姓名
↓
填帳號
↓
填 Department
↓
設定 OU
↓
加入 Group
↓
下一個

不只花時間,也很容易發生:
帳號拼錯
OU 放錯
漏掉 Group
Department 填錯
UPN 重複
同一個人建立兩次

所以 Day 18 要正式做第一套:
CSV-driven AD Onboarding Automation

架構會變成:
NewUsers.csv
↓
Import-Csv
↓
Validation
↓
帳號重複?
UPN 重複?
OU 存在?
Group 存在?
↓
Preview
↓
Create User
↓
Add Groups
↓
Verification
↓
Result Report

但今天仍然維持 Day 17 的原則:
先 Preview,再 Change。

先從 CSV 開始
假設 HR 或 IT 給我們新人資料。
建立:
C:\ADAutomation\Input\NewUsers.csv

內容:
GivenName,Surname,DisplayName,SamAccountName,UserPrincipalName,Department,Title,OU,Groups
Ming,Wang,Ming Wang,ming.wang,ming.wang@contoso.com,IT,System Engineer,"OU=Users,OU=Taipei,DC=contoso,DC=com","IT-Team;VPN-Users"
Amy,Chen,Amy Chen,amy.chen,amy.chen@contoso.com,Finance,Accountant,"OU=Users,OU=Taipei,DC=contoso,DC=com","Finance-Team;VPN-Users"
David,Lin,David Lin,david.lin,david.lin@contoso.com,Sales,Sales Specialist,"OU=Users,OU=Taipei,DC=contoso,DC=com","Sales-Team"

這份 CSV 開始扮演:
Onboarding Input

為什麼 OU 要加雙引號?
因為 Distinguished Name 本身包含:
,

例如:
OU=Users,OU=Taipei,DC=contoso,DC=com

但 CSV 本身也是用:
,

分欄位。
所以一定要:
"OU=Users,OU=Taipei,DC=contoso,DC=com"

否則 CSV 會把 OU 拆成很多欄。
Groups 為什麼使用分號?
跟 Day 12 一樣。
同一個 User 可能需要:
IT-Team
VPN-Users
FileServer-IT-RW

所以 CSV 裡使用:
"IT-Team;VPN-Users;FileServer-IT-RW"

PowerShell 再:
$Groups = $Row.Groups -split ";"

就可以變回:
IT-Team
VPN-Users
FileServer-IT-RW

密碼不要放在 CSV
不要做成:
SamAccountName,Password
ming.wang,Company123!
amy.chen,Company123!

因為:
CSV = 純文字

只要可以開啟檔案,就可以看到所有密碼。
所以今天的 Automation 我故意會先:
建立 Disabled Account。

也就是:
建立 AD User
↓
設定屬性
↓
加入 Group
↓
驗證
↓
帳號仍 Disabled

之後再透過獨立的密碼與啟用流程:
設定 Temporary Password
↓
ChangePasswordAtLogon
↓
Enable Account

這樣安全性會比把新人密碼直接塞在 CSV 好很多。
第一步:Import-Csv
$InputFile =
"C:\ADAutomation\Input\NewUsers.csv"

$Users =
Import-Csv $InputFile

查看:
$Users

可能看到:
GivenName SamAccountName Department Title


Ming ming.wang IT System Engineer
Amy amy.chen Finance Accountant
David david.lin Sales Sales Specialist

現在:
$Users

就是一組 PowerShell Object。
先不要急著 foreach + New-ADUser
很容易寫成:
foreach ($User in $Users) {

New-ADUser ...

}

但這少了最重要的一層:
Validation
在真正建立帳號以前,我至少希望確認:
必要欄位都有嗎?
SamAccountName 重複嗎?
UPN 重複嗎?
AD 裡已經存在嗎?
OU 存在嗎?
Group 存在嗎?

如果其中一項有問題,就先:
Invalid

不要建立。
先檢查 CSV 是否真的有資料
if (-not (Test-Path $InputFile)) {

Write-Error "Input file not found."

return

}

接著:
$Users = @(Import-Csv $InputFile)

if ($Users.Count -eq 0) {

Write-Error "CSV contains no users."

return

}

這是很基本、但非常值得做的防呆。
檢查必要欄位
例如我們要求:
GivenName
Surname
DisplayName
SamAccountName
UserPrincipalName
Department
Title
OU

可以:
$RequiredColumns = @(
"GivenName"
"Surname"
"DisplayName"
"SamAccountName"
"UserPrincipalName"
"Department"
"Title"
"OU"
)

取得 CSV Header:
$Columns =
$Users[0].PSObject.Properties.Name

找缺少的:
$MissingColumns =
$RequiredColumns |
Where-Object {
$_ -notin $Columns
}

如果:
$MissingColumns.Count -gt 0

就停止:
if ($MissingColumns.Count -gt 0) {

Write-Error `
    "Missing columns: $($MissingColumns -join ', ')"

return

}

這比跑到第 37 個 User 才發現:
欄位名稱打錯

好很多。
CSV 裡自己也可能有重複帳號
例如:
Ming,Wang,Ming Wang,ming.wang,...
Ming,Wang,Ming Wang,ming.wang,...

可以先:
$DuplicateAccounts =
$Users |
Group-Object SamAccountName |
Where-Object {
$_.Count -gt 1
}

查看:
$DuplicateAccounts |
Select-Object Name, Count

例如:
Name Count


ming.wang 2

這種就應該:
先修 CSV,再執行。

UPN 也要檢查重複
同樣:
$DuplicateUPNs =
$Users |
Group-Object UserPrincipalName |
Where-Object {
$_.Count -gt 1
}

因為不能只保證:
SamAccountName 不重複

卻忽略:
UserPrincipalName

開始逐筆 Validation
建立:
$ValidationResults = @()

然後:
foreach ($Row in $Users) {

$Errors = @()

$SamAccountName =
    $Row.SamAccountName.Trim()

$UPN =
    $Row.UserPrincipalName.Trim()

$TargetOU =
    $Row.OU.Trim()

# Validation...

}

這裡 $Errors 很重要。
因為同一筆資料可能同時有:
OU 不存在
Group 不存在
UPN 重複

我希望一次全部列出來,而不是每次只告訴一個 Error。
檢查必要資料是不是空白
例如:
if (
[string]::IsNullOrWhiteSpace(
$Row.SamAccountName
)
) {

$Errors +=
    "SamAccountName is empty"

}

UPN:
if (
[string]::IsNullOrWhiteSpace(
$Row.UserPrincipalName
)
) {

$Errors +=
    "UserPrincipalName is empty"

}

OU:
if (
[string]::IsNullOrWhiteSpace(
$Row.OU
)
) {

$Errors +=
    "OU is empty"

}

檢查 SamAccountName 是否已經存在
例如:
$ExistingAccount =
Get-ADUser -Filter "SamAccountName -eq '$SamAccountName'"
-ErrorAction SilentlyContinue

如果:
$null -ne $ExistingAccount

代表:
AD 裡已經有這個 Account。

加入:
$Errors +=
"SamAccountName already exists"

這樣可以避免:
新人建立兩次

或:
CSV 重複執行

造成問題。
UPN 也要查
$ExistingUPN =
Get-ADUser -Filter "UserPrincipalName -eq '$UPN'"
-ErrorAction SilentlyContinue

如果存在:
if ($null -ne $ExistingUPN) {

$Errors +=
    "UserPrincipalName already exists"

}

現在兩種 Identity 都檢查。
OU 是否存在?
例如:
try {

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

}
catch {

$Errors +=
    "OU does not exist"

}

這可以防止:
OU 拼錯
OU 已改名
CSV 填錯 DistinguishedName

Groups 先拆成 Array
$Groups = @()

if (
-not [string]::IsNullOrWhiteSpace(
$Row.Groups
)
) {

$Groups =
    $Row.Groups `
    -split ";" |
    ForEach-Object {
        $_.Trim()
    } |
    Where-Object {
        $_ -ne ""
    }

}

例如:
IT-Team;VPN-Users

變成:
IT-Team
VPN-Users

每個 Group 都先確認存在
foreach ($GroupName in $Groups) {

try {

    Get-ADGroup `
        -Identity $GroupName `
        -ErrorAction Stop |
        Out-Null

}
catch {

    $Errors +=
        "Group not found: $GroupName"
}

}

例如 CSV:
IT-Team
VPN-User

但真正 AD 裡叫:
VPN-Users

Validation 就會抓到:
Group not found: VPN-User

不會等到建立 User 之後才發現少權限。
建立 Preview Result
Validation 完成後:
$ValidationResults +=
[PSCustomObject]@{

    DisplayName =
        $Row.DisplayName

    SamAccountName =
        $SamAccountName

    UserPrincipalName =
        $UPN

    Department =
        $Row.Department

    OU =
        $TargetOU

    Groups =
        $Groups -join ";"

    Ready =
        ($Errors.Count -eq 0)

    ValidationErrors =
        $Errors -join " | "

}

結果可能:
User Account Ready Error
Ming Wang ming.wang True
Amy Chen amy.chen False VPN-Finance not found
David Lin david.lin False UPN already exists

這才是我想要的第一份:
Onboarding Preview Report

Preview 跟 Execution 分開
我不希望:
Validation 做完
↓
同一秒直接 New-ADUser

比較安全的方式是:
Step 1
NewUsers.csv
↓
Validation
↓
Preview CSV

Step 2
人工確認 / Approval

Step 3
Approved Input
↓
Execution

也就是:
Request 跟 Execution 分離。

這是 Day 17 的架構繼續延伸。
Preview 也可以跑 WhatIf
假設這筆 Validation 已通過。
可以:
New-ADUser -Name $Row.DisplayName
-GivenName $Row.GivenName -Surname $Row.Surname
-DisplayName $Row.DisplayName -SamAccountName $Row.SamAccountName
-UserPrincipalName $Row.UserPrincipalName -Department $Row.Department
-Title $Row.Title -Path $Row.OU
-Enabled $false `
-WhatIf

注意:
-Enabled $false

今天我們預設:
先建立 Disabled Account。

為什麼先 Disabled?
假設流程:
Create User
→ Success

Add IT-Team
→ Success

Add VPN
→ Failed

Add File Permission Group
→ Failed

如果 User 一建立就是 Enabled:
帳號可以登入
但權限只建了一半

這可能讓狀態更難處理。
如果:
User 先 Disabled
↓
所有設定完成
↓
Verification
↓
最後才 Activation

安全很多。
這其實是在模擬:
Staging → Activation

正式建立時使用 -PassThru
例如:
$NewUser =
New-ADUser -Name $Row.DisplayName
-GivenName $Row.GivenName -Surname $Row.Surname
-DisplayName $Row.DisplayName -SamAccountName $Row.SamAccountName
-UserPrincipalName $Row.UserPrincipalName -Department $Row.Department
-Title $Row.Title -Path $Row.OU
-Enabled $false -PassThru
-ErrorAction Stop

這裡:
-PassThru

很有用。
因為建立成功後,$NewUser 會拿到新建立的 AD User Object。
後面可以直接:
$NewUser.DistinguishedName

而不用重新猜。
接著加入 Group
foreach ($GroupName in $Groups) {

Add-ADGroupMember `
    -Identity $GroupName `
    -Members $NewUser `
    -ErrorAction Stop

}

例如:
ming.wang
↓
IT-Team
VPN-Users

但是 Group 可能只成功一半
這就是 Day 18 很重要的問題。
假設:
100 個新人

其中第 37 個:
User 建立成功
Group A 成功
Group B 失敗

我們不能:
整支 Script 直接停止

也不能假裝:
Success

所以每個 User 都應該有自己的:
try
catch

以及自己的結果。
不需要第 37 個失敗就停掉全部
比較合理:
User01 → Success
User02 → Success
...
User37 → Partial / Failed
User38 → 繼續
User39 → 繼續
...
User100 → 繼續

最後再輸出:
誰成功?
誰失敗?
失敗在哪裡?

這比:
第 37 個爆掉
↓
後面 63 個全部沒跑

實用很多。
定義幾種 Result Status
我會先用:
Success
Partial
Failed
Skipped

例如:
Success
→ User 建立 + Groups 全部完成

Partial
→ User 建立成功,但部分 Group 失敗

Failed
→ User 本身建立失敗

Skipped
→ Validation 不通過 / User 已存在

這樣報表會比:
OK / NG

清楚很多。
Group 加入失敗,不要立刻 Delete User
可能有人會想到:
Create User 成功
↓
Group 加入失敗
↓
Remove-ADUser

讓它 Rollback。
我反而不建議第一版這樣做。
因為:
Remove-ADUser

本身又是一個破壞性操作。
更安全的做法是:
User 保持 Disabled
↓
Status = Partial
↓
記錄哪個 Group 失敗
↓
人工修正

因為帳號目前 Disabled,不會直接投入使用。
我們保留更多修復空間。
Verification:建立完一定重新 Query
例如:
$VerifyUser =
Get-ADUser -Identity $Row.SamAccountName
-Properties Department, Title, Enabled, MemberOf
-ErrorAction Stop

可以確認:
帳號真的存在?
Department 正確?
Title 正確?
仍然 Disabled?

不要只相信:
New-ADUser 沒有跳 Error

Group Membership 也確認
例如:
$CurrentGroups =
Get-ADPrincipalGroupMembership -Identity $Row.SamAccountName | Select-Object
-ExpandProperty Name

然後:
foreach ($GroupName in $Groups) {

if ($CurrentGroups -notcontains $GroupName) {

    # Verification Failed

}

}

這就是:
Action
↓
Query Again
↓
Verify

今天完整 Preview Script
先做最重要的:
完全不修改 AD 的 Validation / Preview。

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

AD Bulk Onboarding Preview

Day 18

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

Import-Module ActiveDirectory

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

Configuration

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

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

$ReportFolder =
"C:\ADAutomation\Reports"

$Date =
Get-Date -Format "yyyyMMdd"

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

Validate Files

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

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

Write-Error "Input file not found."

return

}

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

New-Item `
    -Path $ReportFolder `
    -ItemType Directory |
    Out-Null

}

$Users =
@(Import-Csv $InputFile)

if ($Users.Count -eq 0) {

Write-Error "No users found in CSV."

return

}

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

Validate Columns

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

$RequiredColumns = @(
"GivenName"
"Surname"
"DisplayName"
"SamAccountName"
"UserPrincipalName"
"Department"
"Title"
"OU"
"Groups"
)

$Columns =
$Users[0].PSObject.Properties.Name

$MissingColumns =
$RequiredColumns |
Where-Object {
$_ -notin $Columns
}

if (@($MissingColumns).Count -gt 0) {

Write-Error `
    "Missing columns: $($MissingColumns -join ', ')"

return

}

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

Duplicate Check

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

$DuplicateAccounts =
$Users |
Group-Object SamAccountName |
Where-Object {
$_.Count -gt 1
}

$DuplicateUPNs =
$Users |
Group-Object UserPrincipalName |
Where-Object {
$_.Count -gt 1
}

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

Validation

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

$PreviewResults = @()

foreach ($Row in $Users) {

$Errors = @()


$SamAccountName =
    [string]$Row.SamAccountName

$UPN =
    [string]$Row.UserPrincipalName

$TargetOU =
    [string]$Row.OU


$SamAccountName =
    $SamAccountName.Trim()

$UPN =
    $UPN.Trim()

$TargetOU =
    $TargetOU.Trim()


# --------------------------------------
# Required Values
# --------------------------------------

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

    $Errors +=
        "SamAccountName is empty"
}


if (
    [string]::IsNullOrWhiteSpace(
        $UPN
    )
) {

    $Errors +=
        "UserPrincipalName is empty"
}


if (
    [string]::IsNullOrWhiteSpace(
        $TargetOU
    )
) {

    $Errors +=
        "OU is empty"
}


# --------------------------------------
# Duplicate in CSV
# --------------------------------------

if (
    $DuplicateAccounts.Name `
    -contains $SamAccountName
) {

    $Errors +=
        "Duplicate SamAccountName in CSV"
}


if (
    $DuplicateUPNs.Name `
    -contains $UPN
) {

    $Errors +=
        "Duplicate UPN in CSV"
}


# --------------------------------------
# Existing AD Account
# --------------------------------------

if (
    -not [string]::IsNullOrWhiteSpace(
        $SamAccountName
    )
) {

    $ExistingAccount =
        Get-ADUser `
            -Filter "SamAccountName -eq '$SamAccountName'" `
            -ErrorAction SilentlyContinue


    if ($null -ne $ExistingAccount) {

        $Errors +=
            "SamAccountName already exists"
    }
}


# --------------------------------------
# Existing UPN
# --------------------------------------

if (
    -not [string]::IsNullOrWhiteSpace(
        $UPN
    )
) {

    $ExistingUPN =
        Get-ADUser `
            -Filter "UserPrincipalName -eq '$UPN'" `
            -ErrorAction SilentlyContinue


    if ($null -ne $ExistingUPN) {

        $Errors +=
            "UserPrincipalName already exists"
    }
}


# --------------------------------------
# OU Validation
# --------------------------------------

if (
    -not [string]::IsNullOrWhiteSpace(
        $TargetOU
    )
) {

    try {

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

    }
    catch {

        $Errors +=
            "OU not found"
    }
}


# --------------------------------------
# Group Validation
# --------------------------------------

$Groups = @()


if (
    -not [string]::IsNullOrWhiteSpace(
        $Row.Groups
    )
) {

    $Groups =
        $Row.Groups `
        -split ";" |
        ForEach-Object {
            $_.Trim()
        } |
        Where-Object {
            $_ -ne ""
        }
}


foreach ($GroupName in $Groups) {

    try {

        Get-ADGroup `
            -Identity $GroupName `
            -ErrorAction Stop |
            Out-Null

    }
    catch {

        $Errors +=
            "Group not found: $GroupName"
    }
}


# --------------------------------------
# Preview Result
# --------------------------------------

$PreviewResults +=
    [PSCustomObject]@{

        DisplayName =
            $Row.DisplayName

        SamAccountName =
            $SamAccountName

        UserPrincipalName =
            $UPN

        Department =
            $Row.Department

        Title =
            $Row.Title

        OU =
            $TargetOU

        Groups =
            $Groups -join ";"

        Ready =
            ($Errors.Count -eq 0)

        ValidationErrors =
            $Errors -join " | "
    }

}

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

Export Preview

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

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

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

Write-Host ""
Write-Host "===== Onboarding Preview ====="
Write-Host ""

$PreviewResults |
Format-Table DisplayName, SamAccountName, Ready, ValidationErrors
-AutoSize

Write-Host ""
Write-Host "Preview Report:"
Write-Host $PreviewFile
Write-Host ""
Write-Host "No Active Directory changes were made."

Preview 執行結果
可能:
DisplayName SamAccountName Ready ValidationErrors


Ming Wang ming.wang True
Amy Chen amy.chen False Group not found: Finance-VPN
David Lin david.lin False SamAccountName already exists

這時候:
Ming Wang
→ Ready

Amy Chen
→ 修正 Group

David Lin
→ 確認是不是重複 Request

還沒有任何 AD 變更。
這就是我們要的。
正式 Execution 的核心邏輯
經過 Review / Approval 後,真正建立 User 的核心可以長這樣:
$ExecutionResults = @()

foreach ($Row in $ApprovedUsers) {

$Groups =
    $Row.Groups -split ";" |
    ForEach-Object {
        $_.Trim()
    } |
    Where-Object {
        $_
    }


try {

    # ----------------------------------
    # Create Disabled User
    # ----------------------------------

    $NewUser =
        New-ADUser `
            -Name $Row.DisplayName `
            -GivenName $Row.GivenName `
            -Surname $Row.Surname `
            -DisplayName $Row.DisplayName `
            -SamAccountName $Row.SamAccountName `
            -UserPrincipalName $Row.UserPrincipalName `
            -Department $Row.Department `
            -Title $Row.Title `
            -Path $Row.OU `
            -Enabled $false `
            -PassThru `
            -ErrorAction Stop


    $FailedGroups = @()


    # ----------------------------------
    # Add Groups
    # ----------------------------------

    foreach ($GroupName in $Groups) {

        try {

            Add-ADGroupMember `
                -Identity $GroupName `
                -Members $NewUser `
                -ErrorAction Stop

        }
        catch {

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


    # ----------------------------------
    # Determine Result
    # ----------------------------------

    if ($FailedGroups.Count -gt 0) {

        $Status = "Partial"

    }
    else {

        $Status = "Success"
    }


    # ----------------------------------
    # Verification
    # ----------------------------------

    $VerifyUser =
        Get-ADUser `
            -Identity $Row.SamAccountName `
            -Properties `
                Department,
                Title,
                Enabled `
            -ErrorAction Stop


    $ExecutionResults +=
        [PSCustomObject]@{

            SamAccountName =
                $Row.SamAccountName

            DisplayName =
                $VerifyUser.Name

            Created =
                $true

            Enabled =
                $VerifyUser.Enabled

            Status =
                $Status

            FailedGroups =
                $FailedGroups -join " | "

            ErrorMessage =
                ""

            CheckTime =
                Get-Date
        }

}
catch {

    $ExecutionResults +=
        [PSCustomObject]@{

            SamAccountName =
                $Row.SamAccountName

            DisplayName =
                $Row.DisplayName

            Created =
                $false

            Enabled =
                $false

            Status =
                "Failed"

            FailedGroups =
                ""

            ErrorMessage =
                $_.Exception.Message

            CheckTime =
                Get-Date
        }
}

}

如果第 37 個失敗會怎樣?
假設:
User01~User36 → Success

User37
→ New-ADUser Failed

User38~User100
→ 繼續

因為每個 User 都有自己的:
try
catch

所以不會因為:
User37

而讓整批工作停止。
最後可能得到:
Success = 96
Partial = 2
Failed = 2

然後只需要針對:
Partial
Failed

進一步處理。
Partial 比 Failed 更值得分開
例如:
ming.wang

User Created = Yes
IT-Team = Success
VPN-Users = Failed

如果報表只寫:
Failed

你不知道帳號到底有沒有建立。
所以我們寫:
Status = Partial

代表:
Object 已經建立,但 Provisioning 沒全部完成。

這在真正 Automation 裡非常重要。
Partial Account 保持 Disabled
這也是為什麼今天一開始:
-Enabled $false

如果:
Group 設定只成功一半

帳號還不能直接投入使用。
我們可以:
Status = Partial
Enabled = False

等 IT 修好後才:
設定 Password
↓
Verify Groups
↓
Enable Account

這比建完就立即 Enabled 安全很多。
最後 Activation 可以獨立做
例如所有設定確認完成後:
$Password =
Read-Host "Enter temporary password"
-AsSecureString

設定密碼:
Set-ADAccountPassword -Identity "ming.wang"
-Reset `
-NewPassword $Password

要求首次登入修改:
Set-ADUser -Identity "ming.wang"
-ChangePasswordAtLogon $true

最後:
Enable-ADAccount `
-Identity "ming.wang"

再確認:
Get-ADUser -Identity "ming.wang" | Select-Object
SamAccountName,
Enabled

預期:
SamAccountName Enabled


ming.wang True

這個 Activation 流程最好有自己的核准與密碼交付規則;正式環境也不應把臨時密碼寫進 CSV 或一般 Log。
最終流程已經很像真正 Onboarding
現在整個架構變成:
HR / IT
│
▼
NewUsers.csv
│
▼
Schema Validation
│
├── Required Field
├── Duplicate Account
├── Duplicate UPN
├── OU Validation
└── Group Validation
│
▼
Preview Report
│
▼
Review / Approval
│
▼
Create Disabled User
│
▼
Set Attributes
│
▼
Add Groups
│
▼
Verification
│
├── Success
├── Partial
└── Failed
│
▼
Password / Activation
│
▼
Final Report

這已經不只是:
New-ADUser

而是:
完整的 Provisioning Workflow。

甚至可以做到 Idempotent
這裡開始接觸一個 Automation 很重要的概念:
Idempotency
簡單說:
同一份 Automation 重跑,不應該一直重複建立相同的東西。

假設:
NewUsers.csv

昨天跑過一次。
今天不小心又跑。
如果 Script 完全沒檢查:
就可能一直報錯

但我們現在會先:
Get-ADUser

確認:
SamAccountName 已存在

然後:
Skipped

而不是重新建立。
也就是:
想建立 User
↓
先看目前 State
↓
已經存在?
↓
不要重複做

這是從 Script 走向 Automation 很重要的觀念。
不要讓 Script 自己猜帳號名稱
例如:
王小明

自動猜成:
ming.wang

看起來很方便。
但可能馬上遇到:
同名
英文名稱不同
姓氏格式不同
特殊字元
既有帳號規則

例如:
ming.wang
ming.wang2
ming.wang01

所以第一版我比較喜歡:
Input CSV 明確提供 SamAccountName 與 UPN。

之後真的有完整命名規範,再把:
Account Naming

獨立成 Function。
不要把 HR 資料直接全部相信
即使 CSV 是別的部門提供的,也仍然要 Validation。
例如:
Department = ITT

可能只是:
IT

打錯。
或者:
OU=Users,OU=ShangHai,...

根本不存在。
Automation 的作用不是:
「把 Input 無條件照做。」

而應該是:
先檢查 Input 是否符合系統規則,再執行。

今天專案結構可以再往前一步
到 Day 18,我會開始整理成:
ADAutomation/
│
├── Input/
│ └── NewUsers.csv
│
├── Config/
│ └── OnboardingConfig.csv
│
├── Scripts/
│ ├── Preview-Onboarding.ps1
│ ├── Invoke-Onboarding.ps1
│ └── Enable-NewUser.ps1
│
├── Reports/
│ ├── Onboarding_Preview.csv
│ └── Onboarding_Result.csv
│
└── Logs/

這裡我特別把:
Preview-Onboarding.ps1

跟:
Invoke-Onboarding.ps1

拆開。
因為:
Preview 跟 Production Change 最好不要只差一個手滑的參數。

Day 18 小結
今天我們正式把:
New-ADUser

從:
建立一個帳號

升級成:
CSV-driven Bulk Onboarding Automation

最重要的流程是:
CSV
↓
Validate Schema
↓
Validate User Data
↓
Check Existing Account
↓
Check UPN
↓
Check OU
↓
Check Groups
↓
Preview
↓
Approval
↓
Create Disabled User
↓
Assign Groups
↓
Verify
↓
Success / Partial / Failed
↓
Activation

今天尤其要記住三件事情。
第一個:
Password 不應該放在 CSV。

第二個:
大量建立 User 時,第 37 筆失敗,不代表後面 63 筆都要跟著失敗。

每一筆都應該獨立:
處理
記錄
繼續

第三個:
先建立 Disabled Account,再完成 Provisioning,最後才 Enable。

這會比:
Create
↓
立刻 Enabled
↓
後面再慢慢設定

更容易控制未完成狀態。
到這裡,我們已經不是單純學:
New-ADUser

而是在設計一個真正比較像企業會使用的:
Identity Provisioning Workflow。

Day 19 預告
Day 19|AD 批次停用離職帳號:從 Offboarding CSV 到安全的 Disable Workflow
Day 18 解決:
新人進來
↓
Create Account

下一篇就處理另一端:
員工離職
↓
Offboarding

我們會建立:
SamAccountName,Ticket,OffboardingDate
user01,INC001001,2026-09-30
user02,INC001002,2026-09-30

然後做:
Offboarding.csv
↓
User Validation
↓
Before-State Snapshot
↓
Group Membership Backup
↓
Preview
↓
Disable Account
↓
移除指定 Sensitive Groups
↓
Move Disabled OU
↓
Update Description
↓
Verification
↓
Result Report

而且 Day 19 會特別處理:
如果帳號 Disable 成功,但 Move OU 失敗,這筆工作到底應該算 Success、Failed,還是 Partial?

也就是正式開始面對 Automation 裡很重要的:
Partial Failure 與 Change State 管理。


上一篇
Day 17|AD 帳號生命週期自動化:建立、停用與離職帳號該怎麼安全處理?
下一篇
Day 19|AD 批次停用離職帳號:從 Offboarding CSV 到安全的 Disable Workflow
系列文
系統工程師的 30 天自動化維運實戰:PowerShell × AD × Windows Server 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言