|
# ============================================================================ |
|
# 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 |
|
} |