Traditional Group Policy management requires administrators to manually link and unlink policies through the Group Policy Management Console (GPMC). This process is time-consuming and error-prone, especially when switching between different security postures. Our solution provides a simple web interface that automates this process while maintaining strict access controls and full logging.
Visual Guide
First, we have the main section on the website, the page loads but then loads the status asynchronously as you can see below:
This is also then reflected on the control buttons and you can see the authorised user that is able to use these buttons, also note that the enable button is disabled as it is already enabled:
If you have no permissions to the control aspect you will will see a red banner and these buttons are disabled:
Finally you will get the activity log that shows your previous operations, this is persistent and gets loaded from the log file dynamically:
System Architecture
The solution consists of three main components:
- ASP.NET Web Interface - Provides the user interface and handles authentication
- PowerShell Script - Manages Group Policy links and enforcement
- Configuration Management - Handles user authorization and policy definitions
┌─────────────────────────────────────────────────────────────────────────────────┐
│ PAM RDP Management System Flow │
└─────────────────────────────────────────────────────────────────────────────────┘
👤 User
│
│ 1. Accesses website
▼
┌─────────────────┐
│ ASP.NET Web │
│ Page │
│ │
│ • Status dots │
│ • Enable/Disable│
│ • Activity logs │
└─────────────────┘
│
│ 2. Automatic login
▼
┌─────────────────┐
│ Windows │
│ Authentication │
│ │
│ • Domain login │
│ • No passwords │
└─────────────────┘
│
│ 3. Check permissions
▼
┌─────────────────┐ ❌ Unauthorized
│ Authorization │────────────────────┐
│ Check │ │
│ │ ▼
│ • web.config │ ┌─────────────┐
│ • User list │ │ Buttons │
└─────────────────┘ │ Grayed Out │
│ │ 🔒 Disabled │
│ ✅ Authorized └─────────────┘
▼
┌─────────────────┐
│ PowerShell │
│ Script │
│ Execution │
│ │
│ • Status check │
│ • Enable/Disable│
│ • Logging │
└─────────────────┘
│
│ 4. Manage GP links
▼
┌─────────────────┐
│ Group Policy │
│ Management │
│ │
│ • Link policies │
│ • Unlink old │
│ • Force update │
└─────────────────┘
│
│ 5. Apply changes
▼
┌─────────────────┐
│ Active Directory│
│ │
│ • PAM policies │
│ • Standard RDP │
│ • OU targets │
└─────────────────┘
Status Indicators:
🟢 Green Dot = PAM Enabled
🔴 Red Dot = PAM Disabled
🟠 Orange Dot = Loading/Unknown
User Experience:
✅ Authorized User = Green banner + Enabled buttons
❌ Unauthorized User = Red banner + Grayed buttons
Prerequisites and Requirements
Before implementing this solution, ensure you have:
Server Requirements
- Windows Server 2016 or later
- IIS with ASP.NET 4.0 support
- PowerShell 5.1 or later
- Group Policy Management Console (GPMC)
- Active Directory PowerShell Module
Service Account Setup
Create a dedicated service account with specific permissions:
# Create service account (run on domain controller)
New-ADUser -Name "svc-grouppolicy" -AccountPassword
(ConvertTo-SecureString "<password>" -AsPlainText -Force) -Enabled $true
# Grant Group Policy permissions
# This must be done through GPMC or PowerShell GP cmdlets
The service account needs:
- Edit Group Policy Objects permission
- Manage Group Policy links permission
- Log on as a service right
- Full control to the application directory
Group Policy Objects
You'll need to create separate GPOs for different security postures:
Lockdown Enforcement Policies:
RDP-Restrict-Access
- Restrictive RDP access controlsRDP-Enhanced-Security
- Enhanced security settingsRDP-Audit-Settings
- Comprehensive audit logging
Standard RDP Policies:
Standard-RDP-Settings
- Normal RDP configurationsDefault-RDP-Security
- Standard security settings
The PowerShell Script Engine
The heart of our system is a PowerShell script that manages Group Policy links. Here's how the core functionality works:
Configuration Management
# Configuration Variables - Customize for your environment
$Config = @{
# Target OU for linking policies
TargetOU = "OU=Servers,DC=bear,DC=local"
# Group Policies for PAM RDP Enforcement
PAMEnforcePolicies = @(
"RDP-Restrict-Access",
"RDP-Enhanced-Security",
"RDP-Audit-Settings"
)
# Group Policies for Normal RDP (when PAM is disabled)
NormalRDPPolicies = @(
"Standard-RDP-Settings",
"Default-RDP-Security"
)
}
This configuration section defines which Group Policies belong to each security posture. The script uses these arrays to determine which policies to link or unlink.
Status Detection Logic
One of the key challenges was creating a reliable status detection system:
function Get-CurrentPolicyState {
try {
Write-LogEntry "Checking current policy state for OU: $($Config.TargetOU)"
# Clean up any existing temp files that might cause conflicts
$tempPath = $env:TEMP
Get-ChildItem -Path $tempPath -Filter "gpt*.tmp" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
# Get currently linked and enabled policies
$linkedPolicies = Get-GPInheritance -Target $Config.TargetOU -ErrorAction Stop |
Select-Object -ExpandProperty GpoLinks |
Where-Object { $_.Enabled -eq $true } |
Select-Object -ExpandProperty DisplayName
# Check which policy set is currently active
$pamPoliciesLinked = $Config.PAMEnforcePolicies | Where-Object { $_ -in $linkedPolicies }
$normalPoliciesLinked = $Config.NormalRDPPolicies | Where-Object { $_ -in $linkedPolicies }
if ($pamPoliciesLinked.Count -gt 0) {
return "PAM_ENABLED"
}
elseif ($normalPoliciesLinked.Count -gt 0) {
return "PAM_DISABLED"
}
else {
return "UNKNOWN"
}
}
catch {
Write-LogEntry "Error checking policy state: $($_.Exception.Message)" "ERROR"
return "ERROR"
}
}
How it works:
- Temporary file cleanup prevents COM exceptions from concurrent GP operations
- Get-GPInheritance retrieves all linked policies for the target OU
- Policy comparison determines which security posture is currently active
- State classification returns a clear status indicator
Policy Link Management
The script handles both adding and removing Group Policy links:
function Remove-PolicyLinks {
param([string[]]$PolicyNames)
foreach ($policyName in $PolicyNames) {
try {
# Check if policy is actually linked before attempting removal
$gpLink = Get-GPInheritance -Target $Config.TargetOU |
Select-Object -ExpandProperty GpoLinks |
Where-Object { $_.DisplayName -eq $policyName }
if ($gpLink) {
Remove-GPLink -Name $policyName -Target $Config.TargetOU -Confirm:$false
Write-LogEntry "Successfully removed policy link: $policyName"
}
else {
Write-LogEntry "Policy not linked, skipping: $policyName" "WARN"
}
}
catch {
Write-LogEntry "Failed to remove policy link: $policyName - $($_.Exception.Message)" "ERROR"
return $false
}
}
return $true
}
function Add-PolicyLinks {
param([string[]]$PolicyNames)
foreach ($policyName in $PolicyNames) {
try {
# Verify GPO exists before linking
$gpo = Get-GPO -Name $policyName -ErrorAction SilentlyContinue
if (!$gpo) {
Write-LogEntry "Group Policy Object not found: $policyName" "ERROR"
return $false
}
# Check if already linked to avoid duplicates
$existingLink = Get-GPInheritance -Target $Config.TargetOU |
Select-Object -ExpandProperty GpoLinks |
Where-Object { $_.DisplayName -eq $policyName }
if (!$existingLink) {
# Create new link with enforcement
New-GPLink -Name $policyName -Target $Config.TargetOU -LinkEnabled Yes -Enforced Yes
Write-LogEntry "Successfully added and enforced policy link: $policyName"
}
else {
# Update existing link to ensure it's enabled and enforced
Set-GPLink -Name $policyName -Target $Config.TargetOU -LinkEnabled Yes -Enforced Yes
Write-LogEntry "Updated existing policy link: $policyName (enabled and enforced)"
}
}
catch {
Write-LogEntry "Failed to add policy link: $policyName - $($_.Exception.Message)" "ERROR"
return $false
}
}
return $true
}
Robust Group Policy Update Handling
One challenge I encountered was the Invoke-GPUpdate
command occasionally failing with COM exceptions. Our solution implements a fallback mechanism:
function Invoke-PolicyUpdate {
try {
Write-LogEntry "Forcing Group Policy update..."
# Try the preferred PowerShell method first
try {
Invoke-GPUpdate -Computer $env:COMPUTERNAME -Force -ErrorAction Stop
Write-LogEntry "Group Policy update completed successfully using Invoke-GPUpdate"
return
}
catch {
Write-LogEntry "Invoke-GPUpdate failed, trying alternative method: $($_.Exception.Message)" "WARN"
}
# Fallback to direct gpupdate.exe execution
try {
$process = Start-Process -FilePath "gpupdate.exe" -ArgumentList "/force" -Wait -PassThru -NoNewWindow
if ($process.ExitCode -eq 0) {
Write-LogEntry "Group Policy update completed successfully using gpupdate.exe"
}
else {
Write-LogEntry "gpupdate.exe returned exit code: $($process.ExitCode)" "WARN"
}
}
catch {
Write-LogEntry "Alternative GP update method also failed: $($_.Exception.Message)" "WARN"
}
}
catch {
Write-LogEntry "Warning: Could not force GP update - $($_.Exception.Message)" "WARN"
}
}
This dual-approach ensures Group Policy updates succeed even when the PowerShell cmdlet encounters issues.
The ASP.NET Web Interface
The web interface provides a professional, user-friendly front-end for the PowerShell operations. Here's how the key components work:
Windows Authentication Integration
private string GetCurrentUser()
{
if (Request.IsAuthenticated && User.Identity.IsAuthenticated)
{
return User.Identity.Name;
}
return HttpContext.Current.User?.Identity?.Name ?? "Anonymous";
}
private bool IsUserAuthorized(string username)
{
if (string.IsNullOrEmpty(username))
return false;
// Normalize username format (handle both DOMAIN\user and user formats)
var normalizedUsername = username.Contains('\\') ?
username.Split('\\')[1].ToLowerInvariant() :
username.ToLowerInvariant();
// Check against authorized users from web.config
return AuthorizedUsers.Any(user =>
user.ToLowerInvariant().Equals(normalizedUsername) ||
user.ToLowerInvariant().Equals(username.ToLowerInvariant())
);
}
Authorization Flow:
- Windows Authentication automatically identifies the user
- Username normalization handles different domain formats
- Authorization check compares against the configured user list
- UI state management enables/disables controls based on permissions
Asynchronous Status Loading
To prevent the web page from hanging during PowerShell execution, we implemented asynchronous status checking:
private void InitializePage()
{
// Get current user and check authorization immediately
string currentUser = GetCurrentUser();
lblCurrentUser.Text = currentUser;
bool isAuthorized = IsUserAuthorized(currentUser);
SetAuthorizationDisplay(isAuthorized);
// Set initial loading state
lblStatus.Text = "Loading current configuration...";
lblLastUpdated.Text = "Checking...";
// Disable buttons while loading
btnEnable.Enabled = false;
btnDisable.Enabled = false;
// Load logs immediately (fast operation)
LoadRecentLogs();
// Trigger asynchronous status check via JavaScript
string script = @"
window.onload = function() {
checkStatusAsync();
};
";
ClientScript.RegisterStartupScript(this.GetType(), "loadStatus", script, true);
}
JavaScript Integration:
function checkStatusAsync() {
// Show loading animation
document.getElementById('<%= lblStatus.ClientID %>').textContent = 'Loading current configuration...';
updateStatusIndicator('LOADING');
// Trigger server-side status check after short delay
setTimeout(function() {
document.getElementById('<%= btnCheckStatus.ClientID %>').click();
}, 500);
}
This approach ensures the page loads immediately while the status check happens in the background.
PowerShell Integration
Rather than requiring the PowerShell SDK, we execute PowerShell scripts directly using Process.Start
:
private PowerShellResult ExecutePowerShellScript(string action)
{
try
{
string scriptCommand = string.Format("\"{0}\" -Action {1}", ScriptPath, action);
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = string.Format("-ExecutionPolicy Bypass -File {0}", scriptCommand),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
};
using (Process process = Process.Start(startInfo))
{
string output = process.StandardOutput.ReadToEnd();
string error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0 || !string.IsNullOrEmpty(error))
{
return new PowerShellResult
{
Success = false,
ErrorMessage = string.IsNullOrEmpty(error) ?
"Script returned exit code: " + process.ExitCode : error
};
}
return new PowerShellResult
{
Success = true,
Output = output.Trim()
};
}
}
catch (Exception ex)
{
return new PowerShellResult
{
Success = false,
ErrorMessage = ex.Message
};
}
}
Flexible Response Parsing
The system handles both JSON and plain text responses from PowerShell:
private void CheckCurrentStatus()
{
try
{
var result = ExecutePowerShellScript("Status");
if (result.Success)
{
string output = result.Output.Trim();
// Check if output looks like JSON
if (output.StartsWith("{") && output.EndsWith("}"))
{
try
{
var serializer = new JavaScriptSerializer();
var scriptResult = serializer.Deserialize<ScriptResult>(output);
CurrentPolicyStatus = scriptResult.CurrentState;
}
catch (Exception jsonEx)
{
// JSON parsing failed, fall back to plain text
CurrentPolicyStatus = output.Contains("PAM_ENABLED") ? "PAM_ENABLED" :
output.Contains("PAM_DISABLED") ? "PAM_DISABLED" : "UNKNOWN";
LogError("JSON parsing failed, used fallback: " + jsonEx.Message);
}
}
else
{
// Handle plain text responses
CurrentPolicyStatus = output.Contains("PAM_ENABLED") ? "PAM_ENABLED" :
output.Contains("PAM_DISABLED") ? "PAM_DISABLED" : "UNKNOWN";
}
lblStatus.Text = GetStatusDisplayText(CurrentPolicyStatus);
}
}
catch (Exception ex)
{
CurrentPolicyStatus = "ERROR";
lblStatus.Text = "Error: " + ex.Message;
LogError("Status check failed", ex);
}
}
Configuration Management
Web.config Setup
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<appSettings>
<!-- Authorized users (supports both DOMAIN\user and user formats) -->
<add key="AuthorizedUsers" value="DOMAIN\admin1,admin1,DOMAIN\admin2,admin2" />
</appSettings>
<system.web>
<compilation debug="true" targetFramework="4.0" />
<httpRuntime targetFramework="4.0" />
<authentication mode="Windows" />
<authorization>
<allow users="*" />
</authorization>
</system.web>
</configuration>
IIS Configuration
Authentication settings must be configured in IIS Manager since they're often locked at the server level:
# Configure via PowerShell
cd "C:\Windows\System32\inetsrv"
# Enable Windows Authentication
.\appcmd.exe set config "Default Web Site/RDPEnforce" -section:system.webServer/security/authentication/windowsAuthentication /enabled:"True"
# Disable Anonymous Authentication
.\appcmd.exe set config "Default Web Site/RDPEnforce" -section:system.webServer/security/authentication/anonymousAuthentication /enabled:"False"
User Experience Features
Visual Status Indicators
The interface provides clear visual feedback through color-coded status indicators that reflect the current Group Policy state:
.status-enabled {
background-color: #27ae60; /* Green dot when PAM is enabled */
}
.status-disabled {
background-color: #e74c3c; /* Red dot when PAM is disabled */
}
.status-unknown {
background-color: #f39c12; /* Orange dot for unknown/loading states */
}
Status Dot Behavior:
- Green Dot: Appears when PAM RDP enforcement is currently active (policies are linked and enforced)
- Red Dot: Appears when PAM RDP enforcement is disabled (standard RDP policies are active)
- Orange Dot: Shows during loading or when the system cannot determine the current state
The status indicator updates in real-time whenever a scan is performed, providing immediate visual confirmation of the current security posture.
function updateStatusIndicator(status) {
var indicator = document.getElementById('statusIndicator');
indicator.className = 'status-indicator';
switch (status) {
case 'PAM_ENABLED':
indicator.classList.add('status-enabled'); // Green dot
break;
case 'PAM_DISABLED':
indicator.classList.add('status-disabled'); // Red dot
break;
case 'LOADING':
indicator.classList.add('status-unknown'); // Orange dot with animation
indicator.style.animation = 'spin 1s linear infinite';
break;
default:
indicator.classList.add('status-unknown'); // Orange dot
indicator.style.animation = 'none';
}
}
Authorization-Based Access Control
The system implements strict access control where only authorized users can perform policy changes. If you're not an authorized user, the action buttons will be grayed out and completely disabled.
Authorization Process:
- User Detection: The system automatically identifies the current Windows user
- Authorization Check: Compares the user against the authorized list in the configuration
- UI State Management: Enables or disables buttons based on authorization status
private bool IsUserAuthorized(string username)
{
if (string.IsNullOrEmpty(username))
return false;
// Normalize username format (handle both DOMAIN\user and user formats)
var normalizedUsername = username.Contains('\\') ?
username.Split('\\')[1].ToLowerInvariant() :
username.ToLowerInvariant();
// Check against authorized users from web.config
return AuthorizedUsers.Any(user =>
user.ToLowerInvariant().Equals(normalizedUsername) ||
user.ToLowerInvariant().Equals(username.ToLowerInvariant())
);
}
private void UpdateButtonStates(bool isAuthorized)
{
if (!isAuthorized)
{
// Gray out buttons for unauthorized users
btnEnable.Enabled = false;
btnDisable.Enabled = false;
return;
}
// ... rest of button logic for authorized users
}
User Experience Based on Authorization:
For Authorized Users:
- See green banner: "You are authorized to edit settings"
- Action buttons are enabled (when appropriate based on current status)
- Can perform Enable/Disable operations
- Full access to all functionality
For Unauthorized Users:
- See red banner: "You do not have permissions to change the settings"
- Action buttons are grayed out and completely disabled
- Can view current status but cannot make changes
- Clear indication of why buttons are disabled
Adding Users to Authorization List
To authorize a user, add them to the web.config file:
<add key="AuthorizedUsers" value="BEAR\lee,lee,BEAR\rdp-enforcer,rdp-enforcer" />
The system supports both domain-qualified (BEAR\username
) and simple (username
) formats, providing flexibility for different Active Directory configurations.
Historical Activity Logging
The system maintains a comprehensive audit trail through automatic log generation. Every action performed through the interface is logged with detailed information for historical and compliance purposes.
What Gets Logged:
- User Actions: Who performed each operation (Enable/Disable)
- Timestamps: Exact date and time of each operation
- Operation Results: Success or failure status of each action
- Policy Changes: Which specific Group Policies were linked or unlinked
- Status Checks: When status scans were performed and their results
- Error Conditions: Any failures or issues encountered during operations
Log Entry Format:
[2024-06-26 14:30:15] [INFO] User BEAR\lee executing PAM RDP action: Enable
[2024-06-26 14:30:16] [INFO] Removing normal RDP policies...
[2024-06-26 14:30:17] [INFO] Successfully removed policy link: Standard-RDP-Settings
[2024-06-26 14:30:18] [INFO] Adding PAM enforcement policies...
[2024-06-26 14:30:19] [INFO] Successfully added and enforced policy link: PAM-RDP-Restrict-Access
[2024-06-26 14:30:20] [INFO] PAM RDP enforcement enabled successfully
[2024-06-26 14:30:21] [INFO] Group Policy update completed successfully using Invoke-GPUpdate
Log Management Features:
- Automatic Generation: Logs are created automatically without user intervention
- Real-time Updates: Log entries are written as operations occur
- Web Interface Display: Recent log entries are displayed in the web interface
- File-based Storage: Logs are stored in a text file for external processing
- Refresh Capability: Users can refresh the log display to see the latest entries
private void LogEntry(string level, string message)
{
try
{
string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
string logEntry = string.Format("[{0}] [{1}] {2}", timestamp, level, message);
// Write to log file for permanent storage
File.AppendAllText(LogPath, logEntry + Environment.NewLine);
}
catch
{
// Ignore logging errors to prevent application failure
}
}
private void LoadRecentLogs()
{
try
{
if (File.Exists(LogPath))
{
var lines = File.ReadAllLines(LogPath);
// Display last 50 entries in the web interface
var recentLines = lines.Skip(Math.Max(0, lines.Length - 50)).ToArray();
litLogContent.Text = string.Join("\n", recentLines);
}
else
{
litLogContent.Text = "No log file found";
}
}
catch (Exception ex)
{
litLogContent.Text = "Error loading logs: " + ex.Message;
}
}
Confirmation Dialogs
Critical operations require explicit confirmation so they cannot be accidently clicked:
<asp:Button ID="btnEnable" runat="server"
Text="Enforce PAM RDP"
OnClick="btnEnable_Click"
OnClientClick="return confirm('Are you sure you want to enable this policy?');" />
Smart Button Management
Buttons are intelligently enabled/disabled based on:
- User authorization - Only authorized users can see enabled buttons
- Current status - Can't enable if already enabled, can't disable if already disabled
- Loading state - Buttons disabled during status checks or operations
File Security Permissions
# Secure the PowerShell script
icacls "C:\inetpub\wwwroot\RDPEnforce\Manage-PAM-RDP.ps1" /grant "BEAR\svc-grouppolicy:R"
icacls "C:\inetpub\wwwroot\RDPEnforce\Manage-PAM-RDP.ps1" /inheritance:r
Deployment Process
Lets quickly go over the deployment process
File Structure
All files are deployed to a single directory for simplicity:
C:\inetpub\wwwroot\RDPEnforce\
├── Default.aspx (Web interface)
├── Web.config (Configuration)
├── Manage-PAM-RDP.ps1 (PowerShell script)
└── PAM-RDP-Management.log (Log file - auto-created)
Application Pool Configuration
# Create dedicated application pool
New-WebAppPool -Name "RDPEnforcePool"
Set-ItemProperty -Path "IIS:\AppPools\PAMRDPManagementPool" -Name "processModel.identityType" -Value "SpecificUser"
Set-ItemProperty -Path "IIS:\AppPools\PAMRDPManagementPool" -Name "processModel.userName" -Value "BEAR\svc-grouppolicy"
Set-ItemProperty -Path "IIS:\AppPools\PAMRDPManagementPool" -Name "processModel.password" -Value "<serviceaccount-password>"
Troubleshooting Common Issues
PowerShell Execution Policy:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine
Group Policy Permissions:
# Verify service account can manage GP links
Get-GPPermission -Name "<GPO-name>" -All | Where-Object {$_.Trustee -like "*svc-grouppolicy*"}
IIS Process Identity:
# Confirm application pool identity
Get-IISAppPool -Name "
RDPEnforce" | Select-Object Name, ProcessModel
Conclusion
The modular design allows for easy extension to additional policy types or integration with other PAM solutions. The asynchronous status checking ensures excellent user experience even when managing complex Group Policy infrastructures.
By leveraging existing Windows infrastructure components (IIS, Active Directory, Group Policy), this solution provides powerful functionality without requiring additional third-party tools or complex architectures.