Public/New-Tier0gMSA.ps1

function New-Tier0gMSA {

    <#
        .SYNOPSIS
            Creates Tier 0 Group Managed Service Accounts in Active Directory.

        .DESCRIPTION
            This function creates the necessary Group Managed Service Accounts (gMSA) for Tier 0 operations.
            It ensures the KDS Root Key exists before creating the gMSAs. The service accounts are
            created in the designated Tier 0 Service Account OU defined in the configuration file.
            The function also adds the created gMSA to the appropriate Tier 0 security group.

        .PARAMETER ConfigXMLFile
            [System.IO.FileInfo] Full path to the XML configuration file.
            Contains all naming conventions, OU structure, and security settings.
            Must be a valid XML file with required schema elements.
            Default: C:\PsScripts\Config.xml

        .PARAMETER DMScripts
            [System.String] Path to all the scripts and files needed by this function.
            The path must exist and contain a 'SecTmpl' subfolder.
            Default: C:\PsScripts\

        .EXAMPLE
            New-Tier0gMSA -ConfigXMLFile "C:\PsScripts\Config.xml" -Verbose

            Creates Tier 0 gMSAs as defined in the configuration file with verbose output.

        .EXAMPLE
            New-Tier0gMSA -ConfigXMLFile "C:\PsScripts\Config.xml" -DMScripts "C:\Scripts\" -WhatIf

            Shows what would happen if the command runs without actually creating the gMSAs.

        .INPUTS
            System.IO.FileInfo, System.String

        .OUTPUTS
            System.String

        .NOTES
            Used Functions:
                Name ║ Module/Namespace
                ═══════════════════════════════════════════╬══════════════════════════════
                Import-MyModule ║ EguibarIT
                Get-FunctionDisplay ║ EguibarIT
                Add-AdGroupNesting ║ EguibarIT
                Get-KdsRootKey ║ ActiveDirectory
                Add-KdsRootKey ║ ActiveDirectory
                Get-ADServiceAccount ║ ActiveDirectory
                New-ADServiceAccount ║ ActiveDirectory
                Set-ADServiceAccount ║ ActiveDirectory
                Write-Verbose ║ Microsoft.PowerShell.Utility
                Write-Warning ║ Microsoft.PowerShell.Utility
                Write-Error ║ Microsoft.PowerShell.Utility

            Version: 1.2
            DateModified: 29/Apr/2025
            LastModifiedBy: Vicente Rodriguez Eguibar
                            vicente@eguibar.com
                            Eguibar IT
                            http://www.eguibarit.com

        .LINK
            https://github.com/vreguibar/EguibarIT

        .COMPONENT
            Active Directory
            Group Managed Service Accounts

        .ROLE
            Infrastructure Administrator
            Domain Administrator

        .FUNCTIONALITY
            Group Managed Service Account Creation and Configuration
    #>


    [CmdletBinding(
        SupportsShouldProcess = $true,
        ConfirmImpact = 'High'
    )]
    [OutputType([System.String])]

    param (

        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            ValueFromRemainingArguments = $false,
            HelpMessage = 'Full path to the configuration.xml file',
            Position = 0
        )]
        [ValidateScript({
                if (-Not ($_ | Test-Path -PathType Leaf) ) {
                    throw ('File not found: {0}' -f $_)
                }
                if ($_.Extension -ne '.xml') {
                    throw ('File must be XML: {0}' -f $_)
                }
                try {
                    [xml]$xml = Get-Content -Path $_ -ErrorAction Stop
                    # Verify required XML elements are present
                    if ($null -eq $xml.n.Admin -or
                        $null -eq $xml.n.Admin.GG -or
                        $null -eq $xml.n.Admin.gMSA -or
                        $null -eq $xml.n.Admin.OUs -or
                        $null -eq $xml.n.NC) {
                        throw 'XML file is missing required elements (Admin, GG, gMSA, OUs or NC section)'
                    }
                    return $true
                } catch {
                    throw ('Invalid XML file: {0}' -f $_.Exception.Message)
                }
            })]
        [PSDefaultValue(
            Help = 'Default Value is "C:\PsScripts\Config.xml"',
            Value = 'C:\PsScripts\Config.xml'
        )]
        [Alias('Config', 'XML', 'ConfigXml')]
        [System.IO.FileInfo]
        $ConfigXMLFile,

        [Parameter(Mandatory = $false,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            ValueFromRemainingArguments = $false,
            HelpMessage = 'Path to all the scripts and files needed by this function',
            Position = 1)]
        [PSDefaultValue(
            Help = 'Default Value is "C:\PsScripts\"',
            Value = 'C:\PsScripts\'
        )]
        [Alias('ScriptPath')]
        [string]
        $DMScripts = 'C:\PsScripts\',

        [Parameter(Mandatory = $false,
            ValueFromPipeline = $false,
            ValueFromPipelineByPropertyName = $false,
            ValueFromRemainingArguments = $false,
            HelpMessage = 'Start transcript logging to DMScripts path with function name',
            Position = 2)]
        [Alias('Transcript', 'Log')]
        [switch]
        $EnableTranscript

    )

    Begin {
        Set-StrictMode -Version Latest

        If (-not $PSBoundParameters.ContainsKey('ConfigXMLFile')) {
            $PSBoundParameters['ConfigXMLFile'] = 'C:\PsScripts\Config.xml'
        } #end If

        If (-not $PSBoundParameters.ContainsKey('DMScripts')) {
            $PSBoundParameters['DMScripts'] = 'C:\PsScripts\'
        } #end If

        # If EnableTranscript is specified, start a transcript
        if ($EnableTranscript) {
            # Ensure DMScripts directory exists
            if (-not (Test-Path -Path $DMScripts -PathType Container)) {
                try {
                    New-Item -Path $DMScripts -ItemType Directory -Force | Out-Null
                    Write-Verbose -Message ('Created transcript directory: {0}' -f $DMScripts)
                } catch {
                    Write-Warning -Message ('Failed to create transcript directory: {0}' -f $_.Exception.Message)
                } #end try-catch
            } #end if

            # Create transcript filename using function name and current date/time
            $TranscriptFile = Join-Path -Path $DMScripts -ChildPath ('{0}_{1}.LOG' -f $MyInvocation.MyCommand.Name, (Get-Date -Format 'yyyyMMdd_HHmmss'))

            try {
                Start-Transcript -Path $TranscriptFile -Force -ErrorAction Stop
                Write-Verbose -Message ('Transcript started: {0}' -f $TranscriptFile)
            } catch {
                Write-Warning -Message ('Failed to start transcript: {0}' -f $_.Exception.Message)
            } #end try-catch
        } #end if

        # Display function header if variables exist
        if ($null -ne $Variables -and
            $null -ne $Variables.Header) {

            $txt = ($Variables.Header -f
                (Get-Date).ToString('dd/MMM/yyyy'),
                $MyInvocation.Mycommand,
                (Get-FunctionDisplay -HashTable $PsBoundParameters -Verbose:$false)
            )
            Write-Verbose -Message $txt
        } #end If

        ##############################
        # Module imports

        Import-MyModule -Name 'ActiveDirectory' -Verbose:$false
        Import-MyModule -Name 'EguibarIT' -Verbose:$false
        Import-MyModule -Name 'EguibarIT.DelegationPS' -Verbose:$false

        ##############################
        # Variables Definition

        # Parameters variable for splatting CMDlets
        [hashtable]$Splat = [hashtable]::New([StringComparer]::OrdinalIgnoreCase)

        # Load the XML configuration file
        try {
            [xml]$confXML = [xml](Get-Content -Path $PSBoundParameters['ConfigXMLFile'] -ErrorAction Stop)

            # Load naming conventions from XML
            [hashtable]$NC = @{
                'sl'    = $confXML.n.NC.LocalDomainGroupPreffix
                'sg'    = $confXML.n.NC.GlobalGroupPreffix
                'su'    = $confXML.n.NC.UniversalGroupPreffix
                'Delim' = $confXML.n.NC.Delimiter
                'T0'    = $confXML.n.NC.AdminAccSufix0
                'T1'    = $confXML.n.NC.AdminAccSufix1
                'T2'    = $confXML.n.NC.AdminAccSufix2
            }

            # ToDo: the GetSafeVariable is finding the variable, but variable has old DN. Interim fix filling the variable again
            $groupName = ('{0}{1}{2}' -f $NC['sg'], $NC['Delim'], $confXML.n.Admin.GG.Tier0ServiceAccount.Name)
            $SG_Tier0ServiceAccount = Get-AdObjectType -Identity $groupName

            # Set the OU DN where the gMSA will be created
            # Generate DN paths for OUs
            [string]$ItAdminOu = $ConfXML.n.Admin.OUs.ItAdminOU.name
            [string]$ItServiceAccountsOu = $ConfXML.n.Admin.OUs.ItServiceAccountsOU.name
            [string]$ItSAT0OU = $ConfXML.n.Admin.OUs.ItSAT0OU.name


            [string]$ItServiceAccountsOuDn = ('OU={0},OU={1},{2}' -f $ItServiceAccountsOu, $ItAdminOu, $Variables.AdDn)
            [string]$ItSAT0OuDn = ('OU={0},{1}' -f $ItSAT0OU, $ItServiceAccountsOuDn)

        } catch {
            Write-Error -Message ('Error reading XML file: {0}' -f $_.Exception.Message)
            throw
        } #end Try-Catch

    } #end Begin

    Process {

        try {
            if ($PSCmdlet.ShouldProcess('Active Directory', 'Create Tier0 Group Managed Service Accounts')) {

                #region KDS Root Key Management
                Write-Debug -Message 'Checking if KDS Root Key exists'

                try {
                    # Check if a KDS Root Key already exists
                    $existingKey = Get-KdsRootKey -ErrorAction SilentlyContinue

                    if (-not $existingKey) {
                        Write-Debug -Message 'No KDS Root Key found. Creating a new key.'


                        # use backdated time approach
                        Write-Debug -Message 'Using backdated time approach.'

                        try {

                            Add-KdsRootKey -EffectiveTime ((Get-Date).AddHours(-12)) -ErrorAction Stop
                            Write-Debug -Message 'KDS Root Key created successfully using backdated time'

                        } catch {

                            Write-Warning -Message (
                                'Error creating KDS Root Key with backdated time: {0}' -f
                                $_.Exception.Message
                            )

                            [System.Text.StringBuilder]$sb = [System.Text.StringBuilder]::new()
                            $sb.AppendLine('Error creating KDS Root Key:')
                            $sb.AppendLine($_.Exception.Message)
                            $sb.AppendLine('* Try adding the KEY from a domain-joined workstation/server.')

                            Write-Warning -Message $sb.ToString()

                        } #end Try-Catch


                    } else {
                        Write-Debug -Message ('KDS Root Key already exists with ID: {0}' -f $existingKey.KeyId)
                    } #end If-Else

                    # Verify KDS Root Key exists after our operation
                    $kdsKey = Get-KdsRootKey -ErrorAction SilentlyContinue

                    if ($kdsKey) {

                        Write-Debug -Message ('Using KDS Root Key with ID: {0}' -f $kdsKey.KeyId)

                    } else {

                        [System.Text.StringBuilder]$sb = [System.Text.StringBuilder]::new()
                        $sb.AppendLine('No KDS Root Key found after creation attempt.')
                        $sb.AppendLine('This may indicate a replication issue or other problem.')
                        $sb.AppendLine('Please check the domain controllers and ensure they are replicating correctly.')
                        $sb.AppendLine(' Group Managed Service Accounts may not function properly.')

                        Write-Warning -Message $sb.ToString()

                    } #end If-Else

                } catch {

                    Write-Error -Message (
                        'Unexpected error when checking or creating KDS Root Key: {0}' -f
                        $_.Exception.Message
                    )
                    # Don't throw here as we want to continue attempting gMSA creation

                } #end Try-Catch
                #endregion KDS Root Key Management

                #region gMSA Creation
                # Check if ServiceAccount exists
                $gMSASamAccountName = '{0}$' -f $confXML.n.Admin.gMSA.AdTaskScheduler.Name
                $AdSchedSAExists = $false

                try {
                    $ExistSA = Get-ADServiceAccount -Filter { SamAccountName -like $gMSASamAccountName } -ErrorAction Stop
                    $AdSchedSAExists = ($null -ne $ExistSA)

                    if ($AdSchedSAExists) {

                        Write-Verbose -Message ('Service Account {0} already exists with DN: {1}' -f
                            $confXML.n.Admin.gMSA.AdTaskScheduler.Name, $ExistSA.DistinguishedName)

                    } #end If

                } catch {

                    Write-Warning -Message ('Error checking if service account exists: {0}' -f $_.Exception.Message)
                    # Continue and try to create it

                } #end Try-Catch

                if (-not $AdSchedSAExists) {
                    Write-Verbose -Message ('Creating service account: {0}' -f $confXML.n.Admin.gMSA.AdTaskScheduler.Name)

                    # Create service account based on OS version
                    if ([System.Environment]::OSVersion.Version.Build -ge 9200) {
                        # Windows Server 2012 or newer
                        $Splat = @{
                            Name                   = $confXML.n.Admin.gMSA.AdTaskScheduler.Name
                            SamAccountName         = $confXML.n.Admin.gMSA.AdTaskScheduler.Name
                            DNSHostName            = ('{0}.{1}' -f $confXML.n.Admin.gMSA.AdTaskScheduler.Name, $env:USERDNSDOMAIN)
                            AccountNotDelegated    = $true
                            Description            = $confXML.n.Admin.gMSA.AdTaskScheduler.Description
                            DisplayName            = $confXML.n.Admin.gMSA.AdTaskScheduler.DisplayName
                            KerberosEncryptionType = 'AES128,AES256'
                            Path                   = $ItSAT0OuDn
                            Enabled                = $true
                            TrustedForDelegation   = $false
                            ServicePrincipalName   = ('HOST/{0}.{1}' -f $confXML.n.Admin.gMSA.AdTaskScheduler.Name, $env:USERDNSDOMAIN)
                            ErrorAction            = 'Stop'
                            PassThru               = $true
                        }

                        $ReplaceValues = @{
                            'company'           = $confXML.n.RegisteredOrg
                            'department'        = $confXML.n.Admin.gMSA.AdTaskScheduler.Department
                            'employeeID'        = 'T0'
                            'employeeType'      = 'ServiceAccount'
                            'info'              = $confXML.n.Admin.gMSA.AdTaskScheduler.Description
                            'title'             = $confXML.n.Admin.gMSA.AdTaskScheduler.DisplayName
                            'userPrincipalName' = '{0}@{1}' -f $confXML.n.Admin.gMSA.AdTaskScheduler.Name, $env:USERDNSDOMAIN
                        }

                        # Add optional attributes conditionally
                        if (-not [string]::IsNullOrEmpty($confXML.n.Admin.gMSA.AdTaskScheduler.c)) {
                            $ReplaceValues.Add('c', $confXML.n.Admin.gMSA.AdTaskScheduler.c)
                        }
                        if (-not [string]::IsNullOrEmpty($confXML.n.Admin.gMSA.AdTaskScheduler.co)) {
                            $ReplaceValues.Add('co', $confXML.n.Admin.gMSA.AdTaskScheduler.co)
                        }
                        if (-not [string]::IsNullOrEmpty($confXML.n.Admin.gMSA.AdTaskScheduler.l)) {
                            $ReplaceValues.Add('l', $confXML.n.Admin.gMSA.AdTaskScheduler.l)
                        }

                        try {
                            Write-Debug -Message 'Creating gMSA with advanced properties'
                            $ExistSA = New-ADServiceAccount @Splat

                            Write-Debug -Message 'Setting additional properties on gMSA'
                            Set-ADServiceAccount -Identity $ExistSA -Replace $ReplaceValues -ErrorAction Stop

                            Write-Verbose -Message ('Successfully created service account: {0}' -f $ExistSA.Name)

                        } catch {

                            Write-Error -Message (
                                'Error when creating AD Scheduler service account: {0}' -f
                                $_.Exception.Message
                            )
                            # Continue to try to use the account if it was created

                        } #end Try-Catch

                    } else {
                        # Older Windows Server
                        $Splat = @{
                            Name        = $confXML.n.Admin.gMSA.AdTaskScheduler.Name
                            Description = $confXML.n.Admin.gMSA.AdTaskScheduler.Description
                            Path        = $ItSAT0OuDn
                            Enabled     = $true
                            ErrorAction = 'Stop'
                            PassThru    = $true
                        }

                        try {

                            Write-Debug -Message 'Creating gMSA with basic properties (older server version)'
                            $ExistSA = New-ADServiceAccount @Splat
                            Write-Debug -Message ('Successfully created service account: {0}' -f $ExistSA.Name)

                        } catch {

                            Write-Error -Message (
                                'Error when creating AD Scheduler service account: {0}' -f
                                $_.Exception.Message
                            )
                            # Continue to try to use the account if it was created

                        } #end Try-Catch

                    } #end If-Else
                } else {

                    Write-Warning -Message (
                        'Service Account {0} already exists.' -f
                        $confXML.n.Admin.gMSA.AdTaskScheduler.Name
                    )

                } #end If-Else
                #endregion gMSA Creation

                #region gMSA Configuration
                # Ensure the gMSA is retrieved for configuration
                if ($null -eq $ExistSA) {

                    try {
                        Write-Debug -Message 'Retrieving the service account for configuration'
                        $ExistSA = Get-ADServiceAccount -Filter { SamAccountName -like $gMSASamAccountName } -ErrorAction Stop

                        if ($null -eq $ExistSA) {
                            throw 'Service account not found'
                        } #end If

                    } catch {

                        Write-Error -Message (
                            'Cannot retrieve service account {0} for configuration: {1}' -f
                            $gMSASamAccountName, $_.Exception.Message
                        )
                        throw
                    } #end Try-Catch
                } #end If

                # Ensure the gMSA is member of Tier0 ServiceAccount group
                try {

                    Write-Debug -Message (
                        'Adding {0} to Tier0 service account group {1}' -f
                        $ExistSA.SamAccountName, $SG_Tier0ServiceAccount
                    )

                    # Define splat for Add-AdGroupNesting
                    $NestingSplat = @{
                        Identity = $SG_Tier0ServiceAccount
                        Members  = $ExistSA
                    }

                    # Check if the gMSA is already a member of the group
                    Add-AdGroupNesting @NestingSplat
                    Write-Debug -Message 'Successfully added gMSA to Tier0 service account group'

                } catch {

                    Write-Error -Message ('Error adding gMSA to Tier0 service account group: {0}' -f $_.Exception.Message)

                } #end Try-Catch

                # Configure gMSA so all members of group "Domain Controllers" can retrieve the password
                try {

                    $Splat = @{
                        Identity                                   = $ExistSA
                        PrincipalsAllowedToRetrieveManagedPassword = 'Domain Controllers'
                        ErrorAction                                = 'Stop'
                    }
                    Set-ADServiceAccount @Splat
                    Write-Debug -Message 'Successfully configured principals allowed to retrieve managed password'

                } catch {

                    Write-Error -Message (
                        'Error configuring principals allowed to retrieve managed password: {0}' -f
                        $_.Exception.Message
                    )
                } #end Try-Catch
                #endregion gMSA Configuration

                # Return the service account
                return $ExistSA.DistinguishedName
            } #end If ShouldProcess
        } catch {

            Write-Error -Message ('Error in New-Tier0gMSA: {0}' -f $_.Exception.Message)
            throw

        } #end Try-Catch

    } #end Process

    End {
        # Display function footer if variables exist
        if ($null -ne $Variables -and
            $null -ne $Variables.Footer) {

            $txt = ($Variables.Footer -f $MyInvocation.InvocationName,
                'Create Tier0 Group Managed Service Accounts.'
            )
            Write-Verbose -Message $txt
        } #end If

        # Stop transcript if it was started
        if ($EnableTranscript) {
            try {
                Stop-Transcript -ErrorAction Stop
                Write-Verbose -Message 'Transcript stopped successfully'
            } catch {
                Write-Warning -Message ('Failed to stop transcript: {0}' -f $_.Exception.Message)
            } #end Try-Catch
        } #end If
    } #end End
} #end Function New-Tier0gMSA