Skip to content

Instantly share code, notes, and snippets.

@fizz
Created February 12, 2026 23:47
Show Gist options
  • Select an option

  • Save fizz/43a393fe60bd558eecdcc3b5f0b661c2 to your computer and use it in GitHub Desktop.

Select an option

Save fizz/43a393fe60bd558eecdcc3b5f0b661c2 to your computer and use it in GitHub Desktop.
WorkSpaces OS-Level Monitoring: SSM Hybrid Activation + CloudWatch Agent bootstrap patterns for CMMC AU-2/AU-3/AU-11
# ============================================================================
# WorkSpaces Monitoring Bootstrap — UAC Elevation Wrapper
# ============================================================================
#
# This script solves the UAC split token problem for AD logon scripts.
#
# THE PROBLEM:
# Even when a WorkSpaces user is in the Domain Admins group, Windows gives
# them a "filtered" (non-elevated) token at logon. Installing services,
# writing to Program Files, and registering SSM agents all need an
# "elevated" (full) token. Normal "Run as Administrator" (Start-Process
# -Verb RunAs) triggers a UAC popup — which may not be visible during
# logon script execution, or may hang the logon process entirely.
#
# THE SOLUTION:
# Start-Process -Credential creates a new process running as the specified
# user with their FULL admin token — no UAC prompt, no popup, no user
# interaction. The spawned process has unrestricted admin privileges.
#
# CREDENTIAL FORMAT — DOWN-LEVEL, NOT UPN:
# You MUST use the "down-level" (NETBIOS) format for the credential:
#
# DOMAIN\Administrator <-- WORKS (down-level / NETBIOS format)
# Administrator@domain.local <-- FAILS SILENTLY on SimpleAD
#
# SimpleAD (Samba 4 AD) does not reliably resolve UPN-format credentials
# in PSCredential objects. The process appears to spawn but runs with a
# broken/anonymous token. AWS Managed AD may handle UPN correctly, but
# down-level format works universally — use it always.
#
# The NETBIOS name is typically the first component of your domain name
# (e.g., "example-corp.local" → NETBIOS name is "example-corp" → use
# "example-corp\Administrator").
#
# SECURITY NOTE:
# The admin password must be available to this script. Options:
# - Terraform templatefile() injection (shown here as placeholder)
# - Read from a secured file on NETLOGON with restricted ACLs
# - Fetch from AWS Secrets Manager (requires existing AWS credentials)
# Store and transmit the password securely. Rotate regularly.
#
# ============================================================================
# CHANGE THIS: Replace with your domain admin password delivery mechanism
# Option A: Terraform template variable (password injected at deploy time)
# $pw = ConvertTo-SecureString "${ad_admin_password}" -AsPlainText -Force
# Option B: Read from a secured file
# $pw = Get-Content "\\example-corp.local\NETLOGON\.admin-cred" | ConvertTo-SecureString -AsPlainText -Force
$pw = ConvertTo-SecureString "CHANGE_THIS_PASSWORD" -AsPlainText -Force
# CHANGE THIS: Replace "EXAMPLE-CORP" with your AD NETBIOS domain name
# Use down-level format (DOMAIN\User), NOT UPN (User@domain.local)
$cred = New-Object System.Management.Automation.PSCredential("EXAMPLE-CORP\Administrator", $pw)
# Copy the main setup script to a local temp path
# (network paths can be unreliable from a -Credential spawned process)
# CHANGE THIS: Replace "example-corp.local" with your AD domain name
$localScript = Join-Path $env:TEMP "setup-monitoring.ps1"
Copy-Item "\\example-corp.local\NETLOGON\setup-monitoring.ps1" $localScript -Force
# Launch the main setup script with full admin privileges
# -Credential: runs as the specified user with their FULL (non-filtered) token
# -Wait: blocks until the child process exits (logon script waits for completion)
# -WindowStyle Hidden: no visible window (runs silently during logon)
Start-Process powershell.exe -Credential $cred `
-ArgumentList "-ExecutionPolicy Bypass -NoProfile -File `"$localScript`"" `
-Wait -WindowStyle Hidden
@echo off
REM ============================================================================
REM WorkSpaces Monitoring Bootstrap — AD Logon Script Entry Point
REM ============================================================================
REM
REM This .bat file is the entry point for the monitoring bootstrap chain.
REM It lives on the NETLOGON share and is referenced by the user's scriptPath
REM LDAP attribute in Active Directory.
REM
REM Why .bat and not .ps1?
REM AD logon scripts (scriptPath) only support .bat/.cmd — Windows silently
REM ignores .ps1 files in the scriptPath attribute. So this one-liner is the
REM trampoline that launches the real PowerShell logic.
REM
REM The chain:
REM 1. This .bat runs at user logon (non-elevated, filtered admin token)
REM 2. Calls elevate-bootstrap.ps1 which elevates to domain admin
REM 3. Which runs setup-monitoring.ps1 with full admin privileges
REM
REM CHANGE THIS: Replace "example-corp.local" with your AD domain name
REM ============================================================================
powershell.exe -ExecutionPolicy Bypass -NoProfile -File "\\example-corp.local\NETLOGON\elevate-bootstrap.ps1" > "%TEMP%\monitoring-bootstrap.log" 2>&1
# ============================================================================
# WorkSpaces Monitoring Bootstrap — Main Setup Script
# ============================================================================
#
# This is the main setup script that runs with full admin privileges (elevated
# by elevate-bootstrap.ps1). It performs all the heavy lifting:
#
# 1. Re-registers SSM Agent in hybrid mode (so WorkSpaces appear in SSM)
# 2. Installs CloudWatch Agent (if not already present)
# 3. Sets AWS_DEFAULT_REGION for hybrid instances (no IMDS available)
# 4. Writes common-config.toml for CW Agent credential resolution
# 5. Fetches CW Agent config from SSM Parameter and starts the agent
# 6. Creates a scheduled task so the agent survives reboots
# 7. Writes a sentinel file so this script only runs once
#
# CMMC Controls Addressed:
# AU-2 — Audit Events (Security, Application, System logs collected)
# AU-3 — Audit Content (full event detail preserved)
# AU-11 — Audit Retention (CloudWatch Logs retention configured server-side)
# AU-9 — Protection of Audit Info (KMS encryption on log groups)
#
# PREREQUISITES:
# - SSM hybrid activation (activation code + ID) from Terraform
# - SSM Parameter containing CloudWatch Agent JSON config
# - Network access to SSM and CloudWatch endpoints from the WorkSpace VPC
#
# ============================================================================
$ErrorActionPreference = "Stop"
$logFile = "$env:TEMP\monitoring-bootstrap.log"
Start-Transcript -Path $logFile -Append
# --- Idempotency guard -----------------------------------------------------------
# A sentinel file prevents this script from running again on subsequent logons.
# This is critical because:
# - SSM re-registration is disruptive (creates a new mi-* ID each time)
# - CloudWatch Agent doesn't need to be reinstalled
# - The logon script fires on EVERY logon, not just the first one
#
# CHANGE THIS: Replace "your-company" with your org identifier
$sentinelFile = "$env:ProgramData\your-company\monitoring-bootstrap-done"
if (Test-Path $sentinelFile) {
Write-Output "Monitoring bootstrap already completed. Exiting."
Stop-Transcript
exit 0
}
Write-Output "=== WorkSpaces Monitoring Bootstrap ==="
Write-Output "Started: $(Get-Date -Format o)"
# --- Step 1: Re-register SSM Agent in hybrid mode --------------------------------
#
# WorkSpaces come with SSM Agent pre-installed, but it's registered against the
# WorkSpaces service infrastructure — not your AWS account. We re-register it
# using a hybrid activation, which gives the WorkSpace an mi-* managed instance
# ID in YOUR account, with YOUR IAM role attached.
#
# The -y flag suppresses the confirmation prompt.
# The -register flag overwrites the existing registration.
#
# CHANGE THIS: Replace activation_code, activation_id, and region with your values
# If using Terraform templatefile(), use ${activation_code}, ${activation_id}, ${region}
$activationCode = "CHANGE_THIS_ACTIVATION_CODE" # From aws_ssm_activation
$activationId = "CHANGE_THIS_ACTIVATION_ID" # From aws_ssm_activation
$awsRegion = "us-east-1" # CHANGE THIS to your region
Write-Output "Configuring SSM Agent for hybrid activation..."
$ssmAgentPath = "$env:ProgramFiles\Amazon\SSM"
if (Test-Path "$ssmAgentPath\amazon-ssm-agent.exe") {
Write-Output "SSM Agent found, re-registering in hybrid mode..."
& "$ssmAgentPath\amazon-ssm-agent.exe" -register -code $activationCode -id $activationId -region $awsRegion -y
Restart-Service AmazonSSMAgent -Force
Write-Output "SSM Agent re-registered and restarted"
} else {
# SSM Agent not pre-installed (unusual for WorkSpaces, but handle it)
Write-Output "SSM Agent not found, downloading..."
$ssmUrl = "https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/windows_amd64/AmazonSSMAgentSetup.exe"
$ssmInstaller = "$env:TEMP\AmazonSSMAgentSetup.exe"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri $ssmUrl -OutFile $ssmInstaller
Start-Process -FilePath $ssmInstaller -ArgumentList "/S" -Wait
Start-Sleep -Seconds 10
& "$ssmAgentPath\amazon-ssm-agent.exe" -register -code $activationCode -id $activationId -region $awsRegion -y
Restart-Service AmazonSSMAgent -Force
Write-Output "SSM Agent installed and registered"
}
# --- Step 2: Install CloudWatch Agent ---------------------------------------------
#
# The CloudWatch Agent collects Windows Event Logs and ships them to CloudWatch Logs.
# We install it from the official S3 URL (always latest version).
Write-Output "Installing CloudWatch Agent..."
$cwAgentPath = "$env:ProgramFiles\Amazon\AmazonCloudWatchAgent"
if (-not (Test-Path "$cwAgentPath\amazon-cloudwatch-agent-ctl.ps1")) {
$cwUrl = "https://s3.amazonaws.com/amazoncloudwatch-agent/windows/amd64/latest/amazon-cloudwatch-agent.msi"
$cwInstaller = "$env:TEMP\amazon-cloudwatch-agent.msi"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri $cwUrl -OutFile $cwInstaller
Start-Process msiexec.exe -ArgumentList "/i `"$cwInstaller`" /quiet /norestart" -Wait
Start-Sleep -Seconds 10
Write-Output "CloudWatch Agent installed"
} else {
Write-Output "CloudWatch Agent already installed"
}
# --- Step 3: Set region for hybrid instances ---------------------------------------
#
# EC2 instances discover their region from IMDS (169.254.169.254). WorkSpaces
# (hybrid instances) have no IMDS, so the CloudWatch Agent doesn't know what
# region to send logs to. We set AWS_DEFAULT_REGION as a machine-level
# environment variable so all AWS SDK calls (including CW Agent) pick it up.
Write-Output "Setting AWS_DEFAULT_REGION for hybrid instance..."
[Environment]::SetEnvironmentVariable("AWS_DEFAULT_REGION", $awsRegion, "Machine")
$env:AWS_DEFAULT_REGION = $awsRegion
# --- Step 4: Write common-config.toml for CW Agent credential resolution ----------
#
# THE PROBLEM:
# On hybrid instances, the SSM Agent writes temporary credentials to the SYSTEM
# user's AWS profile: C:\Windows\System32\config\systemprofile\.aws\credentials
#
# But the CloudWatch Agent's config-downloader runs as the "Administrator" user
# (not SYSTEM), so it looks in Administrator's profile and finds nothing.
#
# THE SOLUTION:
# Write a common-config.toml that tells the CW Agent exactly where to find
# the SYSTEM profile's credentials file. This bridges the identity gap.
#
# Without this file, `fetch-config -s` appears to work (it reads the SSM
# parameter), but the agent silently fails to authenticate when pushing
# logs to CloudWatch.
Write-Output "Writing common-config.toml..."
$configDir = "C:\ProgramData\Amazon\AmazonCloudWatchAgent"
if (-not (Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null }
$configPath = Join-Path $configDir "common-config.toml"
Set-Content -Path $configPath -Value "[credentials]" -Encoding ASCII
Add-Content -Path $configPath -Value "shared_credential_profile = 'default'"
Add-Content -Path $configPath -Value "shared_credential_file = 'C:\Windows\System32\config\systemprofile\.aws\credentials'"
Add-Content -Path $configPath -Value ""
Add-Content -Path $configPath -Value "[ssm]"
Add-Content -Path $configPath -Value "region = '$awsRegion'"
Write-Output "common-config.toml written"
# --- Step 5: Fetch config from SSM and start agent --------------------------------
#
# The CW Agent config lives in an SSM Parameter (SecureString, KMS-encrypted).
# Using -c ssm:<parameter-name> tells the agent to fetch its config from SSM
# rather than a local file. This means you can update the config centrally
# and re-run fetch-config on the agent without redeploying scripts.
#
# Flags:
# -a fetch-config : download and apply configuration
# -m onPrem : hybrid mode (not EC2) — uses common-config.toml for creds
# -s : start the agent after applying config
# -c ssm:<param> : config source is an SSM parameter
#
# CHANGE THIS: Replace with your SSM parameter name
$ssmConfigParameter = "/your-org/workspaces/cloudwatch-agent-config" # CHANGE THIS
Write-Output "Applying CloudWatch Agent configuration..."
& "C:\Program Files\Amazon\AmazonCloudWatchAgent\amazon-cloudwatch-agent-ctl.ps1" `
-a fetch-config -m onPrem -s -c "ssm:$ssmConfigParameter"
Write-Output "CloudWatch Agent configured and started"
# --- Step 6: Create scheduled task for boot persistence ----------------------------
#
# The CloudWatch Agent service may not auto-start on WorkSpaces reboot/rebuild.
# A scheduled task ensures it comes back up reliably.
# Runs as SYSTEM at startup with highest privileges.
Write-Output "Creating scheduled task for agent persistence..."
$taskAction = New-ScheduledTaskAction -Execute "PowerShell.exe" `
-Argument "-NoProfile -Command `"& '$cwAgentPath\amazon-cloudwatch-agent-ctl.ps1' -a start -m onPrem`""
$taskTrigger = New-ScheduledTaskTrigger -AtStartup
$taskPrincipal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName "CloudWatchAgentStart" `
-Action $taskAction -Trigger $taskTrigger -Principal $taskPrincipal `
-Description "Ensure CloudWatch Agent starts on boot" -Force
Write-Output "Scheduled task created"
# --- Step 7: Mark as completed -----------------------------------------------------
#
# Write a sentinel file so this script exits immediately on subsequent logons.
# The sentinel contains the timestamp for audit trail purposes.
#
# CHANGE THIS: Replace "your-company" with your org identifier (must match line ~36)
Write-Output "Creating sentinel file to prevent re-runs..."
$sentinelDir = "$env:ProgramData\your-company"
if (-not (Test-Path $sentinelDir)) { New-Item -ItemType Directory -Path $sentinelDir -Force | Out-Null }
Set-Content -Path $sentinelFile -Value "Bootstrap completed: $(Get-Date -Format o)" -Encoding ASCII
Write-Output "Sentinel file created: $sentinelFile"
# --- Done --------------------------------------------------------------------------
Write-Output "Bootstrap complete - this script will not run again on next logon."
Stop-Transcript
# ============================================================================
# WorkSpaces Monitoring — Terraform Resources
# ============================================================================
#
# Creates all AWS resources needed for WorkSpaces OS-level monitoring:
# - KMS key for CloudWatch Logs encryption (CMMC AU-9)
# - CloudWatch Log Groups for Windows Event Logs (Security, Application, System)
# - IAM role for SSM hybrid managed instances
# - SSM hybrid activation (registers WorkSpaces as mi-* nodes)
# - SSM Parameter with CloudWatch Agent configuration JSON
# - Bootstrap provisioner to deliver scripts to AD NETLOGON share
#
# CMMC L2 Controls:
# AU-2 — Audit Events
# AU-3 — Audit Content
# AU-9 — Protection of Audit Information
# AU-11 — Audit Record Retention
#
# Prerequisites:
# - A VPC with private subnets (for WorkSpaces)
# - A SimpleAD or AWS Managed AD directory
# - An EKS cluster with kubectl access (for the provisioner, or adapt to your needs)
#
# Usage:
# terraform init
# terraform apply -var ad_admin_password="..."
#
# ============================================================================
# ---------------------------------------------------------------------------
# Variables — CHANGE THESE to match your environment
# ---------------------------------------------------------------------------
variable "region" {
description = "AWS region"
type = string
default = "us-east-1" # CHANGE THIS
}
variable "log_retention_days" {
description = "CloudWatch log retention in days (CMMC AU-11 requires minimum 1 year)"
type = number
default = 365
}
variable "ad_admin_password" {
description = "SimpleAD/Managed AD admin password (for bootstrap script delivery)"
type = string
sensitive = true
}
variable "ad_domain_name" {
description = "FQDN of your AD directory (e.g., example-corp.local)"
type = string
default = "example-corp.local" # CHANGE THIS
}
variable "ad_netbios_name" {
description = "NETBIOS name of your AD directory (first component of domain name)"
type = string
default = "example-corp" # CHANGE THIS
}
variable "target_username" {
description = "AD username to set the logon script on"
type = string
}
variable "target_user_cn" {
description = "Full CN of the target AD user (e.g., 'Jane Doe')"
type = string
}
variable "eks_cluster_name" {
description = "EKS cluster name for kubectl provisioner (needs VPC access to AD)"
type = string
default = "my-cluster" # CHANGE THIS
}
# ---------------------------------------------------------------------------
# Data sources
# ---------------------------------------------------------------------------
data "aws_caller_identity" "current" {}
# ---------------------------------------------------------------------------
# KMS Key — CloudWatch Logs encryption for WorkSpaces audit logs
# ---------------------------------------------------------------------------
#
# CloudWatch Logs supports KMS encryption, but the KMS key policy must
# explicitly grant the CloudWatch Logs service principal access.
# The Condition block scopes the key to only our /workspaces/* log groups,
# preventing other log groups from using this key.
resource "aws_kms_key" "workspaces_logs" {
description = "WorkSpaces CloudWatch Logs encryption"
deletion_window_in_days = 30
enable_key_rotation = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "EnableRootAccountPermissions"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
}
Action = "kms:*"
Resource = "*"
},
{
# CloudWatch Logs needs explicit KMS permissions — it doesn't inherit
# from the account root. Without this statement, log group creation
# with kms_key_id fails with AccessDeniedException.
Sid = "AllowCloudWatchLogs"
Effect = "Allow"
Principal = {
Service = "logs.${var.region}.amazonaws.com"
}
Action = [
"kms:Encrypt*",
"kms:Decrypt*",
"kms:ReEncrypt*",
"kms:GenerateDataKey*",
"kms:Describe*"
]
Resource = "*"
Condition = {
ArnLike = {
# Scope to /workspaces/* log groups only
"kms:EncryptionContext:aws:logs:arn" = "arn:aws:logs:${var.region}:${data.aws_caller_identity.current.account_id}:log-group:/workspaces/*"
}
}
}
]
})
tags = {
Name = "workspaces-cloudwatch-logs"
Purpose = "cloudwatch-logs-encryption"
}
}
resource "aws_kms_alias" "workspaces_logs" {
name = "alias/workspaces-cloudwatch-logs"
target_key_id = aws_kms_key.workspaces_logs.key_id
}
# ---------------------------------------------------------------------------
# CloudWatch Log Groups — Windows Event Logs
# ---------------------------------------------------------------------------
#
# Three log groups, one per Windows Event Log channel. Separate groups allow
# different retention policies, access controls, and metric filters per channel.
#
# The Security log is the most critical for compliance — it contains logon
# events, privilege use, policy changes, and object access auditing.
resource "aws_cloudwatch_log_group" "windows_security" {
name = "/workspaces/windows/Security"
retention_in_days = var.log_retention_days
kms_key_id = aws_kms_key.workspaces_logs.arn
tags = {
Name = "workspaces-windows-security"
CMMCControl = "AU-2/AU-3"
CMMCDescription = "Audit Events and Content"
}
}
resource "aws_cloudwatch_log_group" "windows_application" {
name = "/workspaces/windows/Application"
retention_in_days = var.log_retention_days
kms_key_id = aws_kms_key.workspaces_logs.arn
tags = {
Name = "workspaces-windows-application"
CMMCControl = "AU-2"
CMMCDescription = "Audit Events"
}
}
resource "aws_cloudwatch_log_group" "windows_system" {
name = "/workspaces/windows/System"
retention_in_days = var.log_retention_days
kms_key_id = aws_kms_key.workspaces_logs.arn
tags = {
Name = "workspaces-windows-system"
CMMCControl = "AU-2"
CMMCDescription = "Audit Events"
}
}
# ---------------------------------------------------------------------------
# IAM Role — SSM hybrid managed instances (WorkSpaces)
# ---------------------------------------------------------------------------
#
# SSM hybrid activations need an IAM role. When a WorkSpace registers with
# the activation code, it assumes this role. The role needs:
# 1. AmazonSSMManagedInstanceCore — core SSM functionality
# 2. CloudWatch Logs write permissions — to ship event logs
# 3. KMS Decrypt — to write to encrypted log groups
#
# The role is assumed by ssm.amazonaws.com (not by the WorkSpace directly).
resource "aws_iam_role" "workspaces_ssm_managed" {
name = "workspaces-ssm-managed"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Service = "ssm.amazonaws.com"
}
Action = "sts:AssumeRole"
}]
})
tags = {
Name = "workspaces-ssm-managed"
}
}
# Core SSM functionality — Run Command, Session Manager, Patch Manager, etc.
resource "aws_iam_role_policy_attachment" "workspaces_ssm_core" {
role = aws_iam_role.workspaces_ssm_managed.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
# CloudWatch Logs write access — scoped to our specific log groups only
resource "aws_iam_role_policy" "workspaces_ssm_logs" {
name = "cloudwatch-logs"
role = aws_iam_role.workspaces_ssm_managed.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = [
"${aws_cloudwatch_log_group.windows_security.arn}:*",
"${aws_cloudwatch_log_group.windows_application.arn}:*",
"${aws_cloudwatch_log_group.windows_system.arn}:*"
]
}
]
})
}
# KMS access — the CW Agent needs to decrypt/encrypt when writing to
# KMS-encrypted log groups
resource "aws_iam_role_policy" "kms_decrypt" {
name = "kms-decrypt-workspaces-logs"
role = aws_iam_role.workspaces_ssm_managed.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["kms:Decrypt", "kms:GenerateDataKey"]
Resource = aws_kms_key.workspaces_logs.arn
}]
})
}
# ---------------------------------------------------------------------------
# SSM Hybrid Activation — registers WorkSpaces as managed nodes
# ---------------------------------------------------------------------------
#
# This creates an activation code + ID pair. When the bootstrap script runs
# on the WorkSpace, it uses these to register the SSM Agent in hybrid mode.
# The WorkSpace then appears in SSM Fleet Manager with an mi-* instance ID.
#
# registration_limit: how many instances can use this activation (set to your
# expected WorkSpaces count, with headroom for rebuilds)
#
# expiration_date: activations expire. Set to 30 days and re-create as needed.
# After expiration, already-registered instances keep working — only NEW
# registrations are blocked.
#
# IMPORTANT: The activation_code is sensitive. Treat it like a password.
resource "aws_ssm_activation" "workspaces" {
name = "workspaces-hybrid"
iam_role = aws_iam_role.workspaces_ssm_managed.id
registration_limit = 10 # CHANGE THIS: max number of WorkSpaces to register
expiration_date = timeadd(timestamp(), "720h") # 30 days
tags = {
Name = "workspaces-hybrid"
}
lifecycle {
# expiration_date uses timestamp() which changes every plan — ignore it
# to prevent unnecessary recreation
ignore_changes = [expiration_date]
}
}
# ---------------------------------------------------------------------------
# SSM Parameter — CloudWatch Agent configuration
# ---------------------------------------------------------------------------
#
# The CloudWatch Agent config is stored as a SecureString SSM parameter.
# The bootstrap script on each WorkSpace does:
# amazon-cloudwatch-agent-ctl -a fetch-config -m onPrem -s -c ssm:<this-param>
#
# This config collects Windows Event Logs from three channels:
# - Security (logon, privilege use, policy changes — critical for CMMC)
# - Application (app crashes, service errors)
# - System (driver failures, service state changes)
#
# The log_group_name values must match the CloudWatch Log Groups created above.
# Event levels: ERROR + WARNING + INFORMATION gives comprehensive coverage.
# Add "CRITICAL" and "VERBOSE" if you need even more detail.
#
# NOTE: Log stream names will contain ghost EC2 instance IDs (i-0abc...).
# This is normal for hybrid instances — the CW Agent generates a pseudo-ID.
# The real managed instance ID (mi-*) is visible in SSM Fleet Manager.
resource "aws_ssm_parameter" "cloudwatch_agent_config" {
# CHANGE THIS: use your own parameter path
name = "/your-org/workspaces/cloudwatch-agent-config"
type = "SecureString"
key_id = aws_kms_key.workspaces_logs.arn
value = jsonencode({
logs = {
logs_collected = {
windows_events = {
collect_list = [
{
event_name = "Security"
event_levels = ["ERROR", "WARNING", "INFORMATION"]
log_group_name = "/workspaces/windows/Security"
retention_in_days = var.log_retention_days
},
{
event_name = "Application"
event_levels = ["ERROR", "WARNING", "INFORMATION"]
log_group_name = "/workspaces/windows/Application"
},
{
event_name = "System"
event_levels = ["ERROR", "WARNING", "INFORMATION"]
log_group_name = "/workspaces/windows/System"
}
]
}
}
}
})
tags = {
Name = "workspaces-cloudwatch-agent-config"
CMMCControl = "AU-2/AU-3"
CMMCDescription = "CloudWatch Agent configuration for WorkSpaces audit logging"
}
}
# ---------------------------------------------------------------------------
# Bootstrap Provisioner — deliver scripts to AD NETLOGON share
# ---------------------------------------------------------------------------
#
# This uses a temporary EKS pod (Alpine + smbclient + openldap-clients) to:
# 1. Upload the 3 bootstrap scripts to the NETLOGON share via SMB
# 2. Set the scriptPath LDAP attribute on the target user
#
# Why an EKS pod? The NETLOGON share lives on the SimpleAD domain controllers,
# which are only accessible from within the VPC. EKS pods have VPC connectivity;
# your CI/CD runner or local machine likely does not.
#
# ADAPT THIS to your environment:
# - If you have VPC access from your CI/CD runner, use smbclient directly
# - If you use AWS Managed AD, you can use Group Policy instead of scriptPath
# - If you can't use SMB, have the user run the setup script manually
#
# The template files (setup-monitoring.bat, elevate-bootstrap.ps1,
# setup-monitoring.ps1) should be generated using Terraform's templatefile()
# function, injecting the activation code, activation ID, region, SSM parameter
# name, and AD admin password. See the companion scripts in this gist.
locals {
# CHANGE THIS: generate your scripts using templatefile()
# Example:
# bootstrap_ps1 = templatefile("${path.module}/templates/setup-monitoring.ps1.tftpl", {
# activation_code = aws_ssm_activation.workspaces.activation_code
# activation_id = aws_ssm_activation.workspaces.id
# region = var.region
# ssm_config_parameter = aws_ssm_parameter.cloudwatch_agent_config.name
# })
# elevate_ps1 = templatefile("${path.module}/templates/elevate-bootstrap.ps1.tftpl", {
# ad_admin_password = var.ad_admin_password
# })
# setup_bat = file("${path.module}/templates/setup-monitoring.bat")
# Base DN for LDAP operations — derived from AD domain name
# "example-corp.local" → "dc=example-corp,dc=local"
base_dn = join(",", [for part in split(".", var.ad_domain_name) : "dc=${part}"])
}
resource "terraform_data" "monitoring_bootstrap" {
input = {
activation_id = aws_ssm_activation.workspaces.id
activation_code = aws_ssm_activation.workspaces.activation_code
admin_password = var.ad_admin_password
# CHANGE THIS: replace with your AD DNS IP source
# dns_ip = tolist(aws_directory_service_directory.your_dir.dns_ip_addresses)[0]
dns_ip = "10.0.1.100" # CHANGE THIS: AD domain controller IP
username = var.target_username
# CHANGE THIS: base64-encode your rendered templates
# bootstrap_b64 = base64encode(local.bootstrap_ps1)
# elevate_b64 = base64encode(local.elevate_ps1)
# setup_bat_b64 = base64encode(local.setup_bat)
}
provisioner "local-exec" {
command = <<-EOT
set -e
# CHANGE THIS: update kubeconfig for your cluster
aws eks update-kubeconfig --name ${var.eks_cluster_name} --region ${var.region} 2>/dev/null || true
# --- Upload all 3 bootstrap files to NETLOGON via smbclient ---
echo "Uploading bootstrap files to NETLOGON share..."
kubectl run monitoring-bootstrap --rm -i --restart=Never \
--image=alpine:3.19 \
--overrides='{"spec":{"terminationGracePeriodSeconds":0}}' \
--env="AD_PASS=${self.input.admin_password}" \
--env="BASE_DN=${local.base_dn}" \
--env="AD_USER=${self.input.username}" \
--env="DNS_IP=${self.input.dns_ip}" \
--env="SCRIPT_B64=${self.input.bootstrap_b64}" \
--env="ELEVATE_B64=${self.input.elevate_b64}" \
--env="BAT_B64=${self.input.setup_bat_b64}" \
-- sh -c '
set -e
apk add --no-cache -q openldap-clients samba-client 2>/dev/null
# Decode all 3 bootstrap files from base64
echo "$$SCRIPT_B64" | base64 -d > /tmp/setup-monitoring.ps1
echo "$$ELEVATE_B64" | base64 -d > /tmp/elevate-bootstrap.ps1
echo "$$BAT_B64" | base64 -d > /tmp/setup-monitoring.bat
# Upload files to NETLOGON share (use IP, not hostname — DNS may not resolve from pod)
echo "Uploading setup-monitoring.bat to NETLOGON..."
smbclient "//$$DNS_IP/NETLOGON" -U "Administrator%$$AD_PASS" \
-c "put /tmp/setup-monitoring.bat setup-monitoring.bat" 2>/tmp/smb_result || true
echo "Uploading elevate-bootstrap.ps1 to NETLOGON..."
smbclient "//$$DNS_IP/NETLOGON" -U "Administrator%$$AD_PASS" \
-c "put /tmp/elevate-bootstrap.ps1 elevate-bootstrap.ps1" 2>>/tmp/smb_result || true
echo "Uploading setup-monitoring.ps1 to NETLOGON..."
smbclient "//$$DNS_IP/NETLOGON" -U "Administrator%$$AD_PASS" \
-c "put /tmp/setup-monitoring.ps1 setup-monitoring.ps1" 2>>/tmp/smb_result || true
if grep -qi "error\|NT_STATUS" /tmp/smb_result 2>/dev/null; then
echo "WARNING: SMB upload failed. Check SimpleAD security group allows port 445 from EKS pods."
cat /tmp/smb_result
else
# Set scriptPath on the target AD user so the .bat runs at logon
printf "dn: cn=${var.target_user_cn},cn=Users,%%s\nchangetype: modify\nreplace: scriptPath\nscriptPath: setup-monitoring.bat\n" "$$BASE_DN" | \
ldapmodify -H "ldap://$$DNS_IP" -D "cn=Administrator,cn=Users,$$BASE_DN" -w "$$AD_PASS" || \
echo "WARNING: ldapmodify failed - may need manual scriptPath assignment"
echo "Done. Logon script will auto-configure monitoring on first login."
fi
' || echo "Pod execution failed - use manual fallback"
# --- Fallback: manual one-liner for the user ---
echo ""
echo "============================================================"
echo "FALLBACK: If logon script delivery failed, have the user run"
echo "this in an elevated PowerShell on the WorkSpace:"
echo "============================================================"
echo ""
echo "Set-ExecutionPolicy Bypass -Scope Process -Force; iex([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${self.input.bootstrap_b64}')))"
echo ""
EOT
}
depends_on = [
aws_ssm_activation.workspaces,
aws_ssm_parameter.cloudwatch_agent_config,
]
}
# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------
output "ssm_activation_id" {
description = "SSM hybrid activation ID (used in bootstrap script)"
value = aws_ssm_activation.workspaces.id
}
output "ssm_activation_code" {
description = "SSM hybrid activation code (sensitive — treat like a password)"
value = aws_ssm_activation.workspaces.activation_code
sensitive = true
}
output "kms_key_arn" {
description = "KMS key ARN for CloudWatch Logs encryption"
value = aws_kms_key.workspaces_logs.arn
}
output "log_group_names" {
description = "CloudWatch Log Group names for WorkSpaces event logs"
value = {
security = aws_cloudwatch_log_group.windows_security.name
application = aws_cloudwatch_log_group.windows_application.name
system = aws_cloudwatch_log_group.windows_system.name
}
}
output "ssm_parameter_name" {
description = "SSM Parameter name containing CloudWatch Agent config"
value = aws_ssm_parameter.cloudwatch_agent_config.name
}

Amazon WorkSpaces OS-Level Monitoring: SSM Hybrid Activation + CloudWatch Agent

CMMC L2 Controls: AU-2 (Audit Events), AU-3 (Audit Content), AU-11 (Audit Retention)

The Problem

Amazon WorkSpaces don't appear in AWS Systems Manager (SSM) by default. Unlike EC2 instances, WorkSpaces have no instance profile, no EC2 identity document, and no IMDS endpoint. The SSM Agent is pre-installed but registered against the WorkSpaces service infrastructure — not your account. This means:

  • You can't push CloudWatch Agent config via SSM Run Command
  • You can't collect Windows Event Logs to CloudWatch
  • You can't meet CMMC/NIST AU-2/AU-3/AU-11 audit logging requirements
  • WorkSpaces are a compliance blind spot in your environment

The Solution

Register WorkSpaces as SSM hybrid managed instances (mi-* prefix) using an SSM hybrid activation, then bootstrap the CloudWatch Agent to ship Windows Event Logs (Security, Application, System) to CloudWatch Logs with KMS encryption and configurable retention.

Architecture

[WorkSpace boots] --> [AD logon script (setup-monitoring.bat)]
                          |
                          v
                    [elevate-bootstrap.ps1]
                    (UAC elevation via domain admin credentials)
                          |
                          v
                    [setup-monitoring.ps1]
                    (SSM hybrid registration + CloudWatch Agent install + config)
                          |
                          v
                    [CloudWatch Agent fetches config from SSM Parameter]
                          |
                          v
                    [Windows Event Logs --> CloudWatch Logs (KMS encrypted)]

The 3-File Bootstrap Chain

Why three files?

You can't do this in one script. Each file exists because of a specific Windows/AD constraint:

File Format Runs As Purpose
setup-monitoring.bat Batch User (filtered token) AD logon scripts must be .bat — Windows ignores .ps1 in scriptPath
elevate-bootstrap.ps1 PowerShell User (filtered token) Elevates to domain admin via Start-Process -Credential to bypass UAC
setup-monitoring.ps1 PowerShell Domain Admin (full token) Actual work: SSM registration, CW Agent install, config, scheduled task

Why not just run PowerShell directly from the logon script?

SimpleAD (and most AD environments) only support .bat or .cmd files in the scriptPath LDAP attribute. The .bat is a one-line trampoline that launches PowerShell with -ExecutionPolicy Bypass.

The UAC Split Token Problem

Even when a user is a member of the Domain Admins group, Windows gives them a filtered (non-elevated) token at logon. Installing services, writing to C:\Program Files, and registering SSM agents all require an elevated (full) token.

You can't use Start-Process -Verb RunAs in a logon script — that triggers a UAC popup, which may not be visible or may hang the logon. Instead, we use:

$cred = New-Object System.Management.Automation.PSCredential("DOMAIN\Administrator", $securePassword)
Start-Process powershell.exe -Credential $cred -ArgumentList "-File setup-monitoring.ps1" -Wait

Start-Process -Credential creates a new process with the full admin token of the specified user — no UAC prompt, no popup, no user interaction required.

The Down-Level vs UPN Credential Format (SimpleAD Quirk)

When constructing the PSCredential, you must use the down-level format:

DOMAIN\Administrator     <-- THIS WORKS
Administrator@domain.local   <-- THIS FAILS SILENTLY on SimpleAD

SimpleAD (Samba 4 AD) does not reliably resolve UPN-format credentials in PSCredential. The process spawns but runs with a broken token. Use NETBIOS_NAME\Username (the part before the first dot in your AD domain name).

CloudWatch Agent Credential Resolution on Hybrid Instances

On EC2 instances, the CloudWatch Agent gets credentials from IMDS. On hybrid instances (mi-*), it uses credentials deposited by the SSM Agent in the SYSTEM profile. But there's a catch:

The CW Agent's config-downloader runs as Administrator (not SYSTEM), so it can't find the SYSTEM profile's credentials by default. You need a common-config.toml:

[credentials]
shared_credential_profile = 'default'
shared_credential_file = 'C:\Windows\System32\config\systemprofile\.aws\credentials'

[ssm]
region = 'us-east-1'

This tells the CW Agent where to find the credentials that the SSM Agent wrote for the SYSTEM user. Without this file, fetch-config works but the agent can't authenticate to CloudWatch Logs.

You also need AWS_DEFAULT_REGION set as a machine-level environment variable — hybrid instances don't have IMDS to discover their region.

The Ghost EC2 IDs in Log Stream Names

After registration, your CloudWatch log streams will have names like:

i-0a1b2c3d4e5f67890

These look like EC2 instance IDs but they're not real EC2 instances. The CloudWatch Agent on hybrid instances generates a pseudo-instance-ID for the log stream name. This is normal. The actual managed instance ID (mi-*) is visible in SSM Fleet Manager.

Files in This Gist

File Description
setup-monitoring.bat Static .bat entry point for AD logon script
elevate-bootstrap.ps1 UAC elevation wrapper using domain admin credentials
setup-monitoring.ps1 Main bootstrap: SSM registration + CloudWatch Agent setup
terraform-monitoring-resources.tf Terraform IaC for all supporting AWS resources

How to Use These Patterns

  1. Deploy the Terraform (terraform-monitoring-resources.tf) to create KMS keys, log groups, IAM role, SSM activation, and SSM parameter
  2. Customize the scripts — search for CHANGE THIS markers and replace with your values
  3. Upload the 3 scripts to your AD NETLOGON share (or distribute via GPO)
  4. Set scriptPath on target AD users via LDAP or AD Users & Computers
  5. User logs in — monitoring configures itself on first logon, then never runs again (sentinel file)

Prerequisites

  • SimpleAD or AWS Managed AD directory for WorkSpaces authentication
  • VPC connectivity from WorkSpaces to SSM and CloudWatch endpoints
  • Domain admin credentials available to the elevation script (store securely — SSM Parameter, Secrets Manager, etc.)

Compliance Mapping

CMMC Control What This Achieves
AU-2 (Audit Events) Security, Application, System event logs collected
AU-3 (Content of Audit Records) Full event detail including user, timestamp, source, event ID
AU-11 (Audit Record Retention) Configurable CloudWatch Logs retention (default 365 days)
AU-9 (Protection of Audit Information) KMS encryption on all log groups

License

MIT License. Use freely. Attribution appreciated but not required.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment