Lifecycle Workflow Custom Task Extension for Assigning On-Premise Groups to Users

Introduction

Managing user lifecycle events, such as onboarding and role changes, is a critical aspect of identity governance. Microsoft Entra ID Governance provides Lifecycle Workflows, enabling administrators to automate user-related tasks. However, Entra ID Governance workflows do not natively support assigning on-premises Active Directory (AD) groups to users. To bridge this gap, Lifecycle Workflow Custom Task Extension allow you to integrate Automation PowerShell Runbooks and APIs to automate such processes. Lifecycle Workflow Custom Task Extension are a good way to integrate more then just native building blocks.

This blog will guide you through the process of creating a Custom Task Extension to assign on-premise AD groups to users automatically.

architecture

Prerequisites

Before implementing the custom task extension, ensure you have:

  • Microsoft Entra ID Governance with Lifecycle Workflows enabled.
  • Hybrid Identity setup using Azure AD Connect.
  • Microsoft Graph API permissions.
  • A service account with required permissions to modify on-premise AD groups.
  • Logic App to create an Automation Job
  • Azure Automation to execute the script.

Define the Use Case

We aim to automate group assignments in an on-premise Active Directory environment when a new user joins an organization.

Example Scenario

  • A new employee joins the company and must join specific security groups in the on-premise AD.
  • The Lifecycle Workflow triggers an action based on an event (e.g., new hire, promotion, or department change).

Create the Custom Task Extension

Lifecycle Workflows support Custom Task Extensions that execute external scripts via APIs or automation tools. Create a custom extension via Lifecycle Workflow Blade with the following settings. This requires sufficient rights to allow the system to create a Logic App in the background.

Create an Automation Account

To run a PowerShell script on an on-premise system, you need an Automation account. Complete the following steps in preparation.

  • Hybrid Worker Installation on an On-Premise System that can Access the AD DS
  • Runbook Preparation for the executing Powershell on the Hybrid Worker
  • Shared Credentials Setup for access the AD DS with the right Privileges

Create and publish the following runbook. This requires the DomainAdminCreds (Security Risk, working on a updated Version with gMSA) and the following file structure on the Hybrid Worker:

param (
    [string]$UserPrincipalName  # UPN from Entra ID (e.g., user@domain.com)
)

# Import Active Directory module f
Import-Module ActiveDirectory

# Retrieve stored credentials from Azure Automation
$Cred = Get-AutomationPSCredential -Name "DomainAdminCreds"
if (-not $Cred) {
    Write-Output "āŒ ERROR: Failed to retrieve domain admin credentials."
    exit 2
}

# Define log file path (based on user and timestamp)
$LogFolder = "C:\LifecycleWorkflows\Logs"
if (!(Test-Path $LogFolder)) {
    New-Item -ItemType Directory -Path $LogFolder | Out-Null
}
$Timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$LogFile = "$LogFolder\$UserPrincipalName-$Timestamp.log"

# Function to write log output to file
function Write-Log {
    param ([string]$Message)
    $LogEntry = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - $Message"
    Write-Output $LogEntry
    Add-Content -Path $LogFile -Value $LogEntry
}

Write-Log "šŸš€ Starting script for user: $UserPrincipalName"

# Load JSON file
$JsonFilePath = "C:\LifecycleWorkflows\access-packages.json"
$JsonData = Get-Content -Path $JsonFilePath | ConvertFrom-Json

# Fetch AD User using domain admin credentials
$ADUser = Get-ADUser -Filter {UserPrincipalName -eq $UserPrincipalName} -Credential $Cred -Properties SamAccountName, DisplayName, Company, Department, MemberOf

if (-not $ADUser) {
    Write-Log "āŒ ERROR: No matching user found in AD for UPN $UserPrincipalName"
    exit 2
}

# Extract User Details
$UserCompany = $ADUser.Company
$UserDepartment = $ADUser.Department
$UserGroups = $ADUser.MemberOf  

Write-Log "āœ… User Found: $($ADUser.DisplayName)"
Write-Log "   šŸ¢ Company: $UserCompany"
Write-Log "   šŸ›ļø Department: $UserDepartment"
Write-Log "-------------------------------------"

# Function to Add User to AD Group
function Add-UserToGroup {
    param ($GroupName)
    
    # Check if group exists in AD
    $ADGroup = Get-ADGroup -Filter { Name -eq $GroupName } -ErrorAction SilentlyContinue

    if ($ADGroup) {
        # Check if user is already a member
        if ($UserGroups -contains $ADGroup.DistinguishedName) {
            Write-Log "   ā„¹ļø User was already in group: $GroupName (skipping add)"
            return "AlreadyInGroup"
        } else {
            # Try adding user
            try {
                Add-ADGroupMember -Identity $GroupName -Members $ADUser.SamAccountName -ErrorAction Stop
                Write-Log "   āœ… Successfully added user to group: $GroupName"
                return "Success"
            } catch {
                Write-Log "   āŒ ERROR adding user to group: $GroupName - $_"
                return "Failure"
            }
        }
    } else {
        Write-Log "   āš ļø WARNING: Group '$GroupName' not found in AD!"
        return "Failure"
    }
}


# Track success/failure
$SuccessCount = 0
$FailureCount = 0
$AlreadyInGroupCount = 0

# Assign Default Access Packages
Write-Log "`nšŸ”¹ Assigning Default Access Packages..."
foreach ($group in $JsonData.defaultAccessPackage.Default) {
    $result = Add-UserToGroup -GroupName $group
    if ($result -eq "Success") {
        $SuccessCount++
    } elseif ($result -eq "Failure") {
        $FailureCount++
    } elseif ($result -eq "AlreadyInGroup") {
        $AlreadyInGroupCount++
    }
}

# Assign Company Access Packages
Write-Log "`nšŸ”¹ Assigning Company Access Packages..."
if ($UserCompany -and $JsonData.companyAccessPackages.PSObject.Properties.Name -contains $UserCompany) {
    foreach ($group in $JsonData.companyAccessPackages.$UserCompany) {
        $result = Add-UserToGroup -GroupName $group
        if ($result -eq "Success") {
            $SuccessCount++
        } elseif ($result -eq "Failure") {
            $FailureCount++
        } elseif ($result -eq "AlreadyInGroup") {
            $AlreadyInGroupCount++
        }
    }
} else {
    Write-Log "   āŒ No company-specific access packages found for: $UserCompany"
}

# Assign Department Access Packages
Write-Log "`nšŸ”¹ Assigning Department Access Packages..."
if ($UserDepartment -and $JsonData.departmentAccessPackages.PSObject.Properties.Name -contains $UserDepartment) {
    foreach ($group in $JsonData.departmentAccessPackages.$UserDepartment) {
        $result = Add-UserToGroup -GroupName $group
        if ($result -eq "Success") {
            $SuccessCount++
        } elseif ($result -eq "Failure") {
            $FailureCount++
        } elseif ($result -eq "AlreadyInGroup") {
            $AlreadyInGroupCount++
        }
    }
} else {
    Write-Log "   āŒ No department-specific access packages found for: $UserDepartment"
}

# Final Report
Write-Log "`nšŸŽÆ Processing complete!"
Write-Log "āœ… Successfully added to $SuccessCount groups."
Write-Log "ā„¹ļø Already in $AlreadyInGroupCount groups."
Write-Log "āŒ Failed to add to $FailureCount groups."

# Exit codes for Lifecycle Workflows:
# 0 = Success (All groups assigned)
# 1 = Partial Success (Some groups failed)
# 2 = Failure (No groups assigned)
if ($FailureCount -eq 0 -and ($SuccessCount -gt 0 -or $AlreadyInGroupCount -gt 0)) {
    # SUCCESS: All groups were either added or the user was already in them
    Write-Log "šŸŽ‰ SUCCESS: User assigned to all required groups!"
    exit 0
} elseif ($SuccessCount -gt 0 -or $AlreadyInGroupCount -gt 0) {
    # PARTIAL SUCCESS: Some groups were added or already existed, but others failed
    Write-Log "āš ļø PARTIAL SUCCESS: Some groups failed!"
    exit 1
} else {
    # FAILURE: No groups could be assigned
    Write-Log "āŒ FAILURE: No groups could be assigned!"
    exit 2
}

Start-ADSyncSyncCycle -PolicyType Delta

Prepare you Environment for the Script

Here is a small overview of how the file structure should look on the worker, logs are written and the standard groups per company and department are listed in the .json file.

  • C:\LifecycleWorkflows\access-packages.json
  • C:\LifecycleWorkflows\Logs

This solution only assigns the user the groups that he actually needs, i.e. it checks which user attributes have been set and which groups have been released for this e.g. department.

This is handled with the following .json file.

{
  "defaultAccessPackage": {
    "Default": [
      "cloudfil",
      "All-Users-All-Companies",
      "All-Users-cloudfil-Company-SharePointSite"
    ]
  },
  "companyAccessPackages": {
    "cloudfil.ch": [
      "GSG_IT-Department",
      "GSG_CAN_365"
    ],
    "contoso.com": [
      "contoso-MarketingDokumentationen",
      "EID-Contoso-Integration-2026"
    ]
  },
  "departmentAccessPackages": {
    "Digital Transformation": [
      "EID-MarketingMaterials"
    ],
    "HR": [
      "EID-ITProjects",
      "EID-MarketingRecuritmentStuff"
    ],
    "IT": [
      "EID-HR-Tool-Project",
      "GSG_POL_365"
    ]
  }
}

How do I create such a .json file, this is quite simple here I have built a solution called Access Package Builder. Where you can easily export such a .json file, have a look:

https://accesspackagebuilder.azurewebsites.net

Modify and Prepare the Logic App

Now everything is prepared and can be linked to the Logic app. The only parameter that is passed to the runbook is the UserPrincipalName – see below.

Save and test!

Test the Workflow (On-Demand)

The workflow can be easily tested (on-demand). If you want to equip it later with other triggers / automatisms, this is very possible. See the first image in the blog post.

  • Open Workflow
  • Navigate to Execution conditions
  • Select your Test User which is a AD DS User
  • Run workflow

To check that everything has worked properly, there are logs in several places. From the lifecycle workflow status to the automation accounts runbook outputs to the end on the hybrid worker where a log file is written for each user.

Conclusion

Unfortunately, Microsoft’s Identity Governance Solution in Entra ID does not provide the desired solutions when it comes to hybrid infrastructures. Access packages can only be structured with cloud groups. However, if you think the process through to the end, you often also need to assign on-premise groups in the JML processes.
Here I find such a solution very helpful and useful, and it also runs very reliably. If you are interested, take a look at more blog posts here:

Nico Wyss

Writer & Blogger

Be the First in Line!

Sign up for a Newsletter.

You have been successfully Subscribed! Ops! Something went wrong, please try again.

Ā© 2023 Copyright