Task 03: Security Groups Applied to Nodes
DOCUMENT CATEGORY: Implementation Runbook SCOPE: Azure Local post-deployment node configuration PURPOSE: Add domain security groups to local Administrators and Remote Management Users on every cluster node to enforce least-privilege RBAC
Status: Active Applies To: All cluster nodes following Phase 05 cluster deployment Last Updated: 2026-03-10
Add domain Active Directory security groups to the local Administrators and Remote Management Users groups on each cluster node. This enforces least-privilege access so domain identities inherit consistent cluster permissions without using shared local accounts.
This task is optional but strongly recommended for production environments. Without it, cluster node access relies on individual local accounts or Domain Admins only — neither of which provides the scoped, auditable access model required for production operations.
Security Group Requirements
The following groups must exist in Active Directory before running this task (created in Phase 01 Task 02). Groups are sourced from active_directory.security_groups in variables.yml. Only the 5 groups with non-empty local_groups arrays are applied to cluster nodes — WAC groups are applied on the WAC server only.
| YAML Key | Example AD Group | Local Group(s) Assigned | Access Level |
|---|---|---|---|
azure_local_admins | SG-IIC-clus01-AZL-Administrators | Administrators | Full node admin |
operations | SG-IIC-clus01-AZL-Operations | Remote Management Users, Remote Desktop Users | PSRemoting + RDP |
read_only | SG-IIC-clus01-AZL-ReadOnly | Remote Desktop Users, Performance Monitor Users, Event Log Readers | View-only |
hyperv_admins | SG-IIC-clus01-AZL-HyperV-Administrators | Hyper-V Administrators, Remote Management Users | VM management |
storage_admins | SG-IIC-clus01-AZL-Storage-Administrators | Administrators | Storage + CSV |
wac_admins | SG-IIC-clus01-AZL-WAC-Administrators | (WAC server only — local_groups: []) | WAC full admin |
wac_users | SG-IIC-clus01-AZL-WAC-Users | (WAC server only — local_groups: []) | WAC standard |
Both scripts handle the "already a member" condition gracefully — re-running on a node where groups are already applied is safe and will not produce errors.
Variables from variables.yml
| Path | Type | Description |
|---|---|---|
active_directory.security_groups.<key>.name | string | AD security group name |
active_directory.security_groups.<key>.local_groups[] | array | Local group mappings |
compute.cluster_nodes[].management_ip | string | Node management IPs |
identity.ad.domain_netbios | string | Domain NetBIOS name |
Execution Options
- Local Users and Groups
- Orchestrated Script (Mgmt Server)
- Standalone Script
Local Users and Groups (lusrmgr.msc)
When to use: Single node verification, confirming group membership, or manual remediation on one node
Prerequisites
- RDP or console access to each cluster node
- Domain credentials with local admin rights
Procedure
Repeat the following steps on every cluster node:
-
Connect to the node via RDP or console
-
Open Local Users and Groups:
- Press
Win + R, typelusrmgr.msc, press Enter - Navigate to Groups
- For each of the 5 node-applied groups, add the AD group to the corresponding local group:
AD Group (from active_directory.security_groups.<key>.name) | Local Group |
|---|---|
azure_local_admins.name | Administrators |
operations.name | Remote Management Users, Remote Desktop Users |
read_only.name | Remote Desktop Users, Performance Monitor Users, Event Log Readers |
hyperv_admins.name | Hyper-V Administrators, Remote Management Users |
storage_admins.name | Administrators |
For each entry: double-click the local group → Add... → enter <domain-netbios>\<AD group name> → Check Names → OK
- Repeat on all remaining cluster nodes
Validation
- All 5 AD groups are present in their respective local groups on every node
- PSRemoting works for an
operationsgroup member:Enter-PSSession -ComputerName <node> - RDP works for a
read_onlygroup member
Orchestrated Script (Mgmt Server)
When to use: Config-driven automation from a management server or jump box — reads
variables.yml, iterates all cluster nodes via PSRemoting
Script
Primary: scripts/deploy/04-cluster-deployment/phase-06-post-deployment/task-03-security-groups-applied-to-nodes/powershell/Invoke-ApplySecurityGroups-Orchestrated.ps1
Code
# ==============================================================================
# Script : Invoke-ApplySecurityGroups-Orchestrated.ps1
# Purpose: Add AD security groups to local groups on every cluster node,
# driven by active_directory.security_groups[*].local_groups in
# variables.yml. Skips entries where local_groups is empty
# (wac_admins, wac_users — those are applied on the WAC server only).
# Run : From management server — reads variables.yml, uses PSRemoting
# Prereqs: PSRemoting enabled on all nodes, variables.yml accessible,
# powershell-yaml module installed
# ==============================================================================
[CmdletBinding()]
param(
[string]$ConfigPath = "", # Path to variables.yml; defaults to .\config\variables.yml
[PSCredential]$Credential, # Override credential resolution
[string[]]$TargetNode = @(), # Limit to specific nodes; empty = all nodes from config
[switch]$WhatIf, # Dry-run — validate only, no changes
[string]$LogPath = "" # Override log file path
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── Logging ───────────────────────────────────────────────────────────────────
$taskFolderName = "task-03-security-groups-applied-to-nodes"
if (-not $LogPath) {
$logDir = Join-Path (Get-Location).Path "logs\$taskFolderName"
$logFile = Join-Path $logDir ("{0}_{1}_ApplySecurityGroups.log" -f (Get-Date -Format 'yyyy-MM-dd'), (Get-Date -Format 'HHmmss'))
} else {
$logDir = Split-Path $LogPath
$logFile = $LogPath
}
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
function Write-Log {
param([string]$Message, [string]$Level = 'INFO')
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$line = "[$ts][$Level] $Message"
Write-Host $line -ForegroundColor $(switch ($Level) { 'WARN' { 'Yellow' } 'ERROR' { 'Red' } default { 'Cyan' } })
Add-Content -Path $logFile -Value $line
}
Write-Log "Invoke-ApplySecurityGroups-Orchestrated started"
Write-Log "Log: $logFile"
# ── Config loading ────────────────────────────────────────────────────────────
if (-not $ConfigPath) {
$ConfigPath = Join-Path (Get-Location).Path "config\variables.yml"
}
if (-not (Test-Path $ConfigPath)) {
Write-Log "Config file not found: $ConfigPath" 'ERROR'
throw "Config file not found: $ConfigPath"
}
Import-Module powershell-yaml -ErrorAction Stop
Write-Log "Loading config: $ConfigPath"
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Yaml
# ── Build per-group → local_groups mapping (skip entries with empty local_groups) ─
$domainNetbios = $cfg.active_directory.domain.netbios # active_directory.domain.netbios
$sgConfig = $cfg.active_directory.security_groups # active_directory.security_groups
# Only process keyed group entries (hashtable values); skip scalar metadata keys
$groupKeys = $sgConfig.Keys | Where-Object { $sgConfig[$_] -is [System.Collections.IDictionary] }
# Build assignment list: [ { AdGroup, LocalGroups } ... ] — skip where local_groups is empty
$assignments = @()
foreach ($key in $groupKeys) {
$localGroups = @($sgConfig[$key]['local_groups']) # active_directory.security_groups.<key>.local_groups
if ($localGroups.Count -eq 0 -or ($localGroups.Count -eq 1 -and $localGroups[0] -eq $null)) {
Write-Log "Skipping '$key' — local_groups is empty (WAC server only)" 'WARN'
continue
}
$adGroupFqdn = "$domainNetbios\$($sgConfig[$key]['name'])" # active_directory.security_groups.<key>.name
$assignments += [PSCustomObject]@{
Key = $key
AdGroup = $adGroupFqdn
LocalGroups = $localGroups
}
Write-Log "Will assign: $adGroupFqdn → [$($localGroups -join ', ')]"
}
if ($assignments.Count -eq 0) {
Write-Log "No groups with local_groups assignments found — check configuration" 'WARN'
return
}
# ── Determine target nodes ────────────────────────────────────────────────────
$nodes = if ($TargetNode.Count) {
$TargetNode
} else {
$cfg.compute.nodes.Values | ForEach-Object { $_.hostname } # compute.nodes.<key>.hostname
}
Write-Log "Target nodes: $($nodes -join ', ')"
# ── WhatIf guard ──────────────────────────────────────────────────────────────
if ($WhatIf) {
foreach ($a in $assignments) {
Write-Log "[WhatIf] Would add '$($a.AdGroup)' to [$($a.LocalGroups -join ', ')] on: $($nodes -join ', ')" 'WARN'
}
Write-Log "WhatIf complete — no changes made" 'WARN'
return
}
# ── Apply groups to each node ─────────────────────────────────────────────────
$credParam = @{}
if ($Credential) { $credParam['Credential'] = $Credential }
$results = @()
foreach ($node in $nodes) {
Write-Log "[$node] Applying security groups..."
$nodeResult = Invoke-Command -ComputerName $node @credParam -ScriptBlock {
param($Assignments)
$log = @()
$errors = @()
function Add-GroupMemberSafe {
param([string]$LocalGroup, [string]$Member)
try {
Add-LocalGroupMember -Group $LocalGroup -Member $Member -ErrorAction Stop
return "Added '$Member' to '$LocalGroup'"
} catch {
if ($_.Exception.Message -match 'already a member') {
return "'$Member' already in '$LocalGroup' — no change"
}
throw
}
}
$applied = @{}
foreach ($a in $Assignments) {
$ok = $true
foreach ($lg in $a.LocalGroups) {
try {
$log += Add-GroupMemberSafe -LocalGroup $lg -Member $a.AdGroup
} catch {
$errors += "[$($a.Key)] $lg: $($_.Exception.Message)"
$ok = $false
}
}
# Verify each local_group membership
$confirmed = @{}
foreach ($lg in $a.LocalGroups) {
$members = Get-LocalGroupMember -Group $lg | Where-Object ObjectClass -eq 'Group' | Select-Object -ExpandProperty Name
$confirmed[$lg] = $members -contains $a.AdGroup
}
$applied[$a.Key] = [PSCustomObject]@{
AdGroup = $a.AdGroup
Confirmed = $confirmed
Errors = ($errors | Where-Object { $_ -match $a.Key })
}
}
return [PSCustomObject]@{
Success = ($errors.Count -eq 0)
Node = $env:COMPUTERNAME
Applied = $applied
Log = $log
Errors = $errors
}
} -ArgumentList (, $assignments)
foreach ($line in $nodeResult.Log) { Write-Log "[$node] $line" }
foreach ($err in $nodeResult.Errors) { Write-Log "[$node] $err" 'ERROR' }
foreach ($key in $nodeResult.Applied.Keys) {
$a = $nodeResult.Applied[$key]
foreach ($lg in $a.Confirmed.Keys) {
$status = if ($a.Confirmed[$lg]) { 'OK' } else { 'NOT CONFIRMED' }
Write-Log "[$node] $key → $lg : $status"
}
}
$results += [PSCustomObject]@{
Node = $node
Status = if ($nodeResult.Success) { 'Success' } else { 'Failed' }
Applied = $nodeResult.Applied
Errors = $nodeResult.Errors -join '; '
}
}
# ── Summary ───────────────────────────────────────────────────────────────────
Write-Log "Security group application complete"
$results | Format-Table Node, Status, Errors -AutoSize
$failed = $results | Where-Object Status -eq 'Failed'
if ($failed) {
Write-Log "$($failed.Count) node(s) failed — review log: $logFile" 'WARN'
}
Write-Log "Invoke-ApplySecurityGroups-Orchestrated complete"
return $results
Usage Examples
# Reads all values — group assignments driven by local_groups in variables.yml
.\Invoke-ApplySecurityGroups-Orchestrated.ps1 `
-ConfigPath "config\variables.yml"
# Dry-run first
.\Invoke-ApplySecurityGroups-Orchestrated.ps1 `
-ConfigPath "config\variables.yml" `
-WhatIf
# Target a single node
.\Invoke-ApplySecurityGroups-Orchestrated.ps1 `
-ConfigPath "config\variables.yml" `
-TargetNode "iic-01-n01"
Validation
# Verify relevant local group memberships on all nodes remotely
$nodes = @("iic-01-n01", "iic-01-n02")
Invoke-Command -ComputerName $nodes -ScriptBlock {
$localGroupsToCheck = @('Administrators', 'Remote Management Users', 'Remote Desktop Users',
'Performance Monitor Users', 'Event Log Readers', 'Hyper-V Administrators')
foreach ($lg in $localGroupsToCheck) {
$members = Get-LocalGroupMember -Group $lg -ErrorAction SilentlyContinue |
Where-Object ObjectClass -eq 'Group' | Select-Object -ExpandProperty Name
[PSCustomObject]@{
Node = $env:COMPUTERNAME
LocalGroup = $lg
ADGroups = $members -join ', '
}
}
} | Format-Table Node, LocalGroup, ADGroups -AutoSize
Standalone Script
When to use: Copy-paste ready — no
variables.yml, no helpers, no external dependencies. Run from a management server or jump box. All values are defined in the#region CONFIGURATIONblock.
Code
# ==============================================================================
# Script : Invoke-ApplySecurityGroups-Standalone.ps1
# Purpose: Add AD security groups to local groups on each cluster node for all
# 5 node-applied roles — fully self-contained, no variables.yml
# Run : From any management server with PSRemoting access to cluster nodes
# Prereqs: PSRemoting enabled on all target nodes
# ==============================================================================
#region CONFIGURATION
# ── Edit these values to match your environment ──────────────────────────────
# Domain NetBIOS name
$DomainNetbios = "IMPROBABLE"
# Group name prefix — SG-{OrgPrefix}-{ClusterId}-AZL-{role}
$OrgPrefix = "IIC"
$ClusterId = "azurelocal-clus01"
# Cluster nodes to configure
$ClusterNodes = @(
"iic-01-n01",
"iic-01-n02",
"iic-01-n03",
"iic-01-n04"
)
# Credentials for PSRemoting (leave $null to use current session)
$Credential = $null
#endregion CONFIGURATION
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Group name builder
function sg { param([string]$Role) return "$DomainNetbios\SG-$OrgPrefix-$ClusterId-AZL-$Role" }
# Map: AD group → local groups to join (wac_admins / wac_users omitted — WAC server only)
$assignments = @(
[PSCustomObject]@{ Key='azure_local_admins'; AdGroup=(sg 'Administrators'); LocalGroups=@('Administrators') },
[PSCustomObject]@{ Key='operations'; AdGroup=(sg 'Operations'); LocalGroups=@('Remote Management Users','Remote Desktop Users') },
[PSCustomObject]@{ Key='read_only'; AdGroup=(sg 'ReadOnly'); LocalGroups=@('Remote Desktop Users','Performance Monitor Users','Event Log Readers') },
[PSCustomObject]@{ Key='hyperv_admins'; AdGroup=(sg 'HyperV-Administrators'); LocalGroups=@('Hyper-V Administrators','Remote Management Users') },
[PSCustomObject]@{ Key='storage_admins'; AdGroup=(sg 'Storage-Administrators');LocalGroups=@('Administrators') }
)
Write-Host "================================================================"
Write-Host " Apply Security Groups to Cluster Nodes — Standalone"
Write-Host " Domain : $DomainNetbios"
Write-Host " Prefix : $OrgPrefix ClusterId: $ClusterId"
Write-Host " Nodes : $($ClusterNodes -join ', ')"
Write-Host "================================================================"
foreach ($a in $assignments) { Write-Host " $($a.Key): $($a.AdGroup) → [$($a.LocalGroups -join ', ')]" }
Write-Host ""
$credParam = @{}
if ($Credential) { $credParam['Credential'] = $Credential }
$results = @()
foreach ($node in $ClusterNodes) {
Write-Host ""
Write-Host "-- Node: $node"
$nodeResult = Invoke-Command -ComputerName $node @credParam -ScriptBlock {
param($Assignments)
function Add-GroupMemberSafe {
param([string]$LocalGroup, [string]$Member)
try {
Add-LocalGroupMember -Group $LocalGroup -Member $Member -ErrorAction Stop
return "Added '$Member' to '$LocalGroup'"
} catch {
if ($_.Exception.Message -match 'already a member') {
return "'$Member' already in '$LocalGroup' — no change"
}
throw
}
}
$log = @(); $errors = @()
foreach ($a in $Assignments) {
foreach ($lg in $a.LocalGroups) {
try { $log += Add-GroupMemberSafe -LocalGroup $lg -Member $a.AdGroup }
catch { $errors += "[$($a.Key)] $lg : $($_.Exception.Message)" }
}
}
# Verify
$verified = @{}
foreach ($a in $Assignments) {
foreach ($lg in $a.LocalGroups) {
$members = Get-LocalGroupMember -Group $lg -ErrorAction SilentlyContinue |
Where-Object ObjectClass -eq 'Group' | Select-Object -ExpandProperty Name
$verified["$($a.Key)|$lg"] = $members -contains $a.AdGroup
}
}
return [PSCustomObject]@{
Node = $env:COMPUTERNAME
Success = ($errors.Count -eq 0)
Log = $log
Errors = $errors
Verified = $verified
}
} -ArgumentList (, $assignments)
foreach ($line in $nodeResult.Log) { Write-Host " $line" }
foreach ($err in $nodeResult.Errors) { Write-Host " ERROR: $err" -ForegroundColor Red }
foreach ($k in $nodeResult.Verified.Keys) {
$ok = if ($nodeResult.Verified[$k]) { 'OK' } else { 'NOT CONFIRMED' }
Write-Host " [$k] : $ok"
}
$results += [PSCustomObject]@{
Node = $node
Status = if ($nodeResult.Success) { 'Success' } else { 'Failed' }
Errors = $nodeResult.Errors -join '; '
}
}
Write-Host ""
Write-Host "================================================================"
Write-Host " Summary"
$results | Format-Table Node, Status, Errors -AutoSize
Write-Host "================================================================"
All values are defined in the #region CONFIGURATION block at the top. Edit $OrgPrefix and $ClusterId to match your environment — group names are built automatically. Requires PSRemoting access to each cluster node.
Validation Summary
| Check | Command | Expected Result |
|---|---|---|
Administrators membership | Get-LocalGroupMember -Group 'Administrators' | Where-Object ObjectClass -eq 'Group' | azure_local_admins and storage_admins groups listed on every node |
Remote Management Users | Get-LocalGroupMember -Group 'Remote Management Users' | Where-Object ObjectClass -eq 'Group' | operations and hyperv_admins groups listed on every node |
Remote Desktop Users | Get-LocalGroupMember -Group 'Remote Desktop Users' | Where-Object ObjectClass -eq 'Group' | operations and read_only groups listed on every node |
Hyper-V Administrators | Get-LocalGroupMember -Group 'Hyper-V Administrators' | Where-Object ObjectClass -eq 'Group' | hyperv_admins group listed on every node |
| PSRemoting test | Enter-PSSession -ComputerName <node> -Credential <ops-user> | Session opens without error |
Troubleshooting
| Issue | Cause | Resolution |
|---|---|---|
| Security group not listed in local group membership | GPO not applied or AD replication delay | Run gpupdate /force on the node; verify AD group exists: Get-ADGroup -Identity <groupname> |
| PSRemoting test fails with access denied | User not in Remote Management Users group | Add the user's group to Remote Management Users: Add-LocalGroupMember -Group "Remote Management Users" -Member "<domain>\<group>" |
| Group membership shows SID instead of name | Domain controller unreachable for name resolution | Verify DC connectivity: Test-ComputerSecureChannel; check DNS resolution to domain controllers |
Navigation
| Previous | Up | Next |
|---|---|---|
| Task 02: Cluster Quorum | Phase 06 Index | Task 04: SSH Connectivity |