├── CODE_OF_CONDUCT.md ├── README.md ├── LICENSE ├── CONTRIBUTING.md └── ADWorkspaceAutomation.yml /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## My Project 2 | 3 | TODO: Fill this README out! 4 | 5 | Be sure to: 6 | 7 | * Change the title in this README 8 | * Edit your repository description on GitHub 9 | 10 | ## Security 11 | 12 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 13 | 14 | ## License 15 | 16 | This library is licensed under the MIT-0 License. See the LICENSE file. 17 | 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /ADWorkspaceAutomation.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: 'This CloudFormation template creates a solution to automate the registration of a Workspace Directory,Creates a maintenance task to perform AD actions, and manages Amazon WorkSpaces instance lifecycles according to the presence of users in an AD OU.' 3 | # Sample code, software libraries, command line tools, proofs of concept, templates, or other related technology are provided as AWS Content or Third-Party Content under the AWS Customer Agreement, or the relevant written agreement between you and AWS (whichever applies). You should not use this AWS Content or Third-Party Content in your production accounts, or on production or other critical data. You are responsible for testing, securing, and optimizing the AWS Content or Third-Party Content, such as sample code, as appropriate for production grade use based on your specific quality control practices and standards. Deploying AWS Content or Third-Party Content may incur AWS charges for creating or using AWS chargeable resources, such as running Amazon EC2 instances or using Amazon S3 storage. Refer to associated README for additional guidance and considerations. 4 | Metadata: 5 | AWS::CloudFormation::Interface: 6 | ParameterGroups: 7 | - 8 | Label: 9 | default: "Directory Information" 10 | Parameters: 11 | - DirectoryID 12 | - DomainOUPath 13 | - wsADSecret 14 | - 15 | Label: 16 | default: "Network Information" 17 | Parameters: 18 | - WorkspaceSubnetOne 19 | - WorkspaceSubnetTwo 20 | - 21 | Label: 22 | default: "Management Instance Information" 23 | Parameters: 24 | - AdInstanceID 25 | - DriveLetter 26 | - 27 | Label: 28 | default: "Workspace Information" 29 | Parameters: 30 | - WorkSpaceRoleChoice 31 | - DirectoryRegistrationChoice 32 | - BundleID 33 | - 34 | Label: 35 | default: "S3 Information" 36 | Parameters: 37 | - UsersBucketName 38 | 39 | Parameters: 40 | 41 | # S3 bucket unique name 42 | UsersBucketName: 43 | Type: String 44 | Description: 'The name of S3 bucket to used store the CSV file containing the user list. Must be an unique name. (ie: ws-dr-bucket-080119)' 45 | Default: '' 46 | 47 | # Domain joined instance ID 48 | AdInstanceID: 49 | Type: String 50 | Description: 'The EC2 Instance ID of domain joined Windows Server. (ie: i-012a3b4c567d8e901)' 51 | Default: '' 52 | 53 | # AD Connector or Microsoft AD directory ID 54 | DirectoryID: 55 | Type: String 56 | Description: 'The ID of the AWS Directory Services component (ie: d-01234a567b).' 57 | Default: '' 58 | 59 | # WorkSpaces bundle ID 60 | BundleID: 61 | Type: String 62 | Description: 'The ID of the Amazon WorkSpaces Bundle (ie: wsb-abc0defgh).' 63 | Default: '' 64 | 65 | # Windows drive for PS1 script 66 | DriveLetter: 67 | Type: String 68 | Description: 'The drive on the AD controller where the maintenance task will run and use as temp space for downloading the csv file. (ie: C)' 69 | Default: 'C' 70 | 71 | # Secrets Manager ARN for service account 72 | wsADSecret: 73 | Type: String 74 | Description: 'The ARN of the Secrets Manager entry for the Active Directory Service Account' 75 | Default: '' 76 | 77 | # Subnet 1 for Workspace Deployment 78 | WorkspaceSubnetOne: 79 | Type: String 80 | Description: 'The first SubnetID for Workspace deployment' 81 | Default: '' 82 | 83 | # Subnet 2 for Workspace Deployment 84 | WorkspaceSubnetTwo: 85 | Type: String 86 | Description: 'The second SubnetID for Workspace deployment' 87 | Default: '' 88 | 89 | # AD Root OU where WorkspaceUsers OU will be created 90 | DomainOUPath: 91 | Type: String 92 | Description: 'The Active Directory DN where the WorkspaceUsers OU will be created (e.g. OU=company,Dc=corp,DC=example,DC=com)' 93 | Default: '' 94 | 95 | # Choice to deploy workspaces_DefaultRole 96 | WorkSpaceRoleChoice: 97 | Type: String 98 | Default: false 99 | AllowedValues: [true, false] 100 | Description: 'Select true to create the workspaces_DefaultRole in this account.' 101 | 102 | #Choice to have directory registered with Workspaces 103 | DirectoryRegistrationChoice: 104 | Type: String 105 | Default: false 106 | AllowedValues: [true, false] 107 | Description: 'Select true to enable the Workspace to be registered with the Directory.' 108 | 109 | Conditions: 110 | CreateWSDefaultRole: !Equals [true, !Ref WorkSpaceRoleChoice] 111 | CreateDirectoryRegistration: !Equals [true, !Ref DirectoryRegistrationChoice] 112 | 113 | Resources: 114 | 115 | # Create AD Management Server Role 116 | AdMgtServerRole: 117 | Type: 'AWS::IAM::Role' 118 | Properties: 119 | ManagedPolicyArns: 120 | - !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore 121 | #- !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonSSMDirectoryServiceAccess 122 | AssumeRolePolicyDocument: 123 | Statement: 124 | - Effect: Allow 125 | Principal: 126 | Service: 127 | - ec2.amazonaws.com 128 | - ssm.amazonaws.com 129 | Action: 130 | - 'sts:AssumeRole' 131 | Path: / 132 | RoleName: 'AD-Management-Role' 133 | 134 | # Create AD Management Server Policies 135 | AdMgtServerPolicy: 136 | Type: 'AWS::IAM::Policy' 137 | Properties: 138 | PolicyName: 'AD-Management-Policy' 139 | PolicyDocument: 140 | Version: 2012-10-17 141 | Statement: 142 | - Sid: PutObject 143 | Effect: Allow 144 | Action: 145 | - 's3:GetObject' 146 | - 's3:PutObject' 147 | - 's3:PutObjectAcl' 148 | - 's3:GetEncryptionConfiguration' 149 | - 's3:Get*' 150 | Resource: 151 | - !Sub '${UsersBucket.Arn}/*' 152 | - Sid: UseSecretsManager 153 | Effect: Allow 154 | Action: 155 | - 'secretsmanager:CreateSecret' 156 | - 'secretsmanager:PutSecretValue' 157 | - 'secretsmanager:UpdateSecret' 158 | - 'secretsmanager:TagResource' 159 | Resource: !Sub 'arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:*' 160 | - Sid: GetSecrets 161 | Effect: Allow 162 | Action: 163 | - 'secretsmanager:GetSecretValue' 164 | Resource: !Sub 'arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:ws.users.*' 165 | - Sid: GetADSecret 166 | Effect: Allow 167 | Action: 168 | - 'secretsmanager:GetSecretValue' 169 | Resource: !Ref wsADSecret 170 | - Sid: SecretsManagerGlobalRequired 171 | Effect: Allow 172 | Action: 173 | - 'secretsmanager:ListSecrets' 174 | - 'secretsmanager:GetRandomPassword' 175 | Resource: '*' 176 | Roles: 177 | - !Ref AdMgtServerRole 178 | 179 | # Create the EC2 Instance Profile to be used by the AD Management Server 180 | InstanceProfileAdMgt: 181 | Type: AWS::IAM::InstanceProfile 182 | Properties: 183 | InstanceProfileName: InstanceProfileADManagement 184 | Path: / 185 | Roles: 186 | - !Ref AdMgtServerRole 187 | 188 | #Create the workspaces_DefaultRole - required for Workspace Directory registration 189 | WorkspacesDefaultRole: 190 | Type: AWS::IAM::Role 191 | Condition: CreateWSDefaultRole 192 | Properties: 193 | AssumeRolePolicyDocument: 194 | Version: '2012-10-17' 195 | Statement: 196 | - Effect: Allow 197 | Principal: 198 | Service: 199 | - workspaces.amazonaws.com 200 | Action: 201 | - sts:AssumeRole 202 | Description: Workspaces-Default-Role 203 | ManagedPolicyArns: 204 | - !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonWorkSpacesServiceAccess 205 | - !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonWorkSpacesSelfServiceAccess 206 | RoleName: workspaces_DefaultRole #Do not customize. This specific name is required. 207 | 208 | # Create Lambda function to register Managed AD/AD Connector with Workspaces 209 | RegisterDirectory: 210 | Type: AWS::Lambda::Function 211 | Condition: CreateDirectoryRegistration 212 | DependsOn: WorkspacesDefaultRole 213 | Properties: 214 | Code: 215 | ZipFile: | 216 | import logging 217 | import boto3 218 | import json 219 | import cfnresponse as cfn 220 | logger = logging.getLogger() 221 | logger.setLevel(logging.INFO) 222 | workspaces = boto3.client('workspaces') 223 | def lambda_handler(event, context): 224 | DirectoryId = event['ResourceProperties']['Directory_Id'] 225 | SubnetId1 = event['ResourceProperties']['Subnet_Id1'] 226 | SubnetId2 = event['ResourceProperties']['Subnet_Id2'] 227 | try: 228 | response = workspaces.register_workspace_directory(DirectoryId=DirectoryId,SubnetIds=[SubnetId1,SubnetId2],EnableWorkDocs=False,EnableSelfService=False) 229 | print('Successfully Registered Directory') 230 | cfn.send(event, context, cfn.SUCCESS, {"Message": "Directory Registration successful!"}) 231 | logger.info('SUCCESS!') 232 | except Exception as e: 233 | print("Error: " + str(e)) 234 | logger.info('FAILED! - Exception during Directory Registration') 235 | cfn.send(event, context, cfn.FAILED, {"Message": "Directory Registration NOT successful!"}) 236 | return 237 | Role: !GetAtt RegistrationExecutionRole.Arn 238 | Description: Register Managed AD with Workspaces 239 | Handler: index.lambda_handler 240 | Runtime: python3.9 241 | Timeout: 90 242 | FunctionName: 'WorkspaceRegistration' 243 | 244 | # Create IAM Role for Lambda function to register Managed AD/AD Connector with Workspaces 245 | RegistrationExecutionRole: 246 | Type: AWS::IAM::Role 247 | Condition: CreateDirectoryRegistration 248 | Properties: 249 | AssumeRolePolicyDocument: 250 | Version: '2012-10-17' 251 | Statement: 252 | - Effect: Allow 253 | Principal: 254 | Service: 255 | - lambda.amazonaws.com 256 | Action: 257 | - sts:AssumeRole 258 | Path: / 259 | Description: Role for Lambda Execution of Workspace Directory Registration 260 | RoleName: 'WorkspaceRegistrationExecutionRole' 261 | ManagedPolicyArns: 262 | - !Sub arn:${AWS::Partition}:iam::aws:policy/AWSDirectoryServiceFullAccess 263 | Policies: 264 | - PolicyName: DirectoryRegistration 265 | PolicyDocument: 266 | Version: '2012-10-17' 267 | Statement: 268 | - Effect: Allow 269 | Action: 270 | - workspaces:RegisterWorkspaceDirectory 271 | - iam:GetRole 272 | - ec2:Describe* 273 | - workdocs:RegisterDirectory 274 | Resource: '*' 275 | - Effect: Allow 276 | Action: 277 | - logs:CreateLogGroup 278 | - logs:CreateLogStream 279 | - logs:PutLogEvents 280 | - logs:DescribeLogGroups 281 | Resource: !Sub arn:${AWS::Partition}:logs:region:account-id:* 282 | 283 | # Invoke the Lambda function to register Managed AD/AD Connector with Workspaces 284 | RegisterDirectoryInvoke: 285 | Type: Custom::RegisterDirectory 286 | Condition: CreateDirectoryRegistration 287 | Properties: 288 | ServiceToken: !GetAtt RegisterDirectory.Arn 289 | FunctionName: 'WorkspaceRegistration' 290 | Directory_Id: !Ref DirectoryID 291 | Subnet_Id1: !Ref WorkspaceSubnetOne 292 | Subnet_Id2: !Ref WorkspaceSubnetTwo 293 | 294 | 295 | 296 | # Create S3 bucket 297 | UsersBucket: 298 | Type: AWS::S3::Bucket 299 | Properties: 300 | BucketName: !Ref UsersBucketName 301 | BucketEncryption: 302 | ServerSideEncryptionConfiguration: 303 | - ServerSideEncryptionByDefault: 304 | SSEAlgorithm: AES256 305 | PublicAccessBlockConfiguration: 306 | BlockPublicAcls: true 307 | BlockPublicPolicy: true 308 | IgnorePublicAcls: true 309 | RestrictPublicBuckets: true 310 | NotificationConfiguration: 311 | LambdaConfigurations: 312 | - Event: 's3:ObjectCreated:Put' 313 | Function: !GetAtt LambdaCompare.Arn 314 | Filter: 315 | S3Key: 316 | Rules: 317 | - 318 | Name: prefix 319 | Value: workspaces-lifecycle 320 | - 321 | Name: suffix 322 | Value: .csv 323 | DependsOn: 324 | - LambdaComparePermission 325 | 326 | # Create maintenance window role 327 | MaintenanceWindowRole: 328 | Type: AWS::IAM::Role 329 | Properties: 330 | RoleName: 'ws-automation-window-role' 331 | AssumeRolePolicyDocument: 332 | Version: 2012-10-17 333 | Statement: 334 | - Effect: Allow 335 | Principal: 336 | Service: 337 | - ssm.amazonaws.com 338 | Action: 339 | - sts:AssumeRole 340 | Path: / 341 | ManagedPolicyArns: 342 | - arn:aws:iam::aws:policy/service-role/AmazonSSMMaintenanceWindowRole 343 | Policies: 344 | - PolicyName: pass-role 345 | PolicyDocument: 346 | Version: 2012-10-17 347 | Statement: 348 | - Effect: Allow 349 | Action: 350 | - iam:PassRole 351 | Resource: 352 | - 'arn:aws:ssm:region:account-id:*' 353 | Condition: 354 | StringEquals: 355 | 'iam:PassedToService': 356 | - ssm.amazonaws.com 357 | 358 | # Create maintenance window 359 | MaintenanceWindow: 360 | Type: AWS::SSM::MaintenanceWindow 361 | Properties: 362 | Name: 'ws-automation-maintenance-window' 363 | Schedule: 'cron(0 */15 * ? * *)' 364 | Duration: 3 365 | Cutoff: 1 366 | AllowUnassociatedTargets: true 367 | DependsOn: 368 | - MaintenanceWindowRole 369 | - UsersBucket 370 | 371 | # Create maintenance window task 372 | MaintenanceWindowTask: 373 | Type: AWS::SSM::MaintenanceWindowTask 374 | Properties: 375 | Name: 'ws-automation-maintenance-window-task' 376 | WindowId: !Sub '${MaintenanceWindow}' 377 | Targets: 378 | - Key: InstanceIds 379 | Values: 380 | - !Ref AdInstanceID 381 | TaskType: 'RUN_COMMAND' 382 | ServiceRoleArn: !GetAtt MaintenanceWindowRole.Arn 383 | TaskArn: 'AWS-RunPowerShellScript' 384 | TaskInvocationParameters: 385 | MaintenanceWindowRunCommandParameters: 386 | Parameters: 387 | commands: 388 | - !Sub | 389 | # Install tools if not already setup 390 | if (!(Test-ComputerSecureChannel)) { 391 | write-Output "Server is not domain joined - cannot proceed - exiting" 392 | return 393 | } 394 | else { 395 | if ((Get-WindowsOptionalFeature -FeatureName RSAT-ADDS-Tools -Online).State -eq "Enabled") { 396 | write-Output "tools already installed" 397 | } 398 | else { 399 | Import-Module ServerManager 400 | Add-WindowsFeature RSAT-ADDS-Tools 401 | Add-WindowsFeature RSAT-AD-PowerShell 402 | import-module ActiveDirectory 403 | import-module AWSPowerShell 404 | } 405 | } 406 | 407 | # Begin workspace creation automation 408 | $workspaceUsersOU="OU=WorkspaceUsers,${DomainOUPath}" 409 | $bucket = "${UsersBucketName}" 410 | $wslocalPath = '${DriveLetter}:\workspaces-users\' 411 | 412 | if (Test-Path -Path $wslocalPath) { Write-Output "Path Exists" } 413 | else { 414 | write-Output "Creating $wslocalPath" 415 | New-Item -Path $wslocalPath -ItemType directory -ErrorAction Stop -ErrorVariable +ErrVar 416 | } 417 | 418 | Get-ADUser -Filter * -SearchBase $workspaceUsersOU -Properties SamAccountname | % {New-Object PSObject -Property @{oSamAccountname= $_.SamAccountname}} | select-object oSamAccountname | export-CSV ${DriveLetter}:\\workspaces-users\\workspaces-users1.csv -NoTypeInformation -Encoding UTF8 419 | Get-Content ${DriveLetter}:\\workspaces-users\\workspaces-users1.csv | Select-Object -Skip 1 | Set-Content ${DriveLetter}:\\workspaces-users\\workspaces-users.csv 420 | Write-S3Object -BucketName $bucket -Key workspaces-lifecycle/workspaces-users.csv -File ${DriveLetter}:\\workspaces-users\\workspaces-users.csv 421 | # End workspace creation automation 422 | 423 | # Begin user lifecycle automation 424 | $prefix = "userlifecycle/UserList.csv" 425 | $key = "UserList.csv" 426 | $template = "userlifecycle/UserListTemplate.csv" 427 | 428 | $BaseDN="${DomainOUPath}" 429 | 430 | $localPath = '${DriveLetter}:\lifecycle-users\' 431 | $localFileName = $localPath + 'New' + $key 432 | $oldhashfile = $localPath + 'Old' + $key 433 | 434 | $newUserFile = $localPath + $key 435 | 436 | if (Test-Path -Path $localPath) { Write-Output "Path Exists" } 437 | else { 438 | write-Output "Creating $localPath" 439 | New-Item -Path $localPath -ItemType directory -ErrorAction Stop -ErrorVariable +ErrVar 440 | } 441 | try { 442 | $metadata = Get-S3ObjectMetadata -BucketName $bucket -Key $prefix 443 | write-Output "UserList.csv Found, Processing File..." 444 | } 445 | catch { 446 | write-Output "UserList.csv File not found in S3 $bucket, Creating Template File.." 447 | Set-Content -Path $newUserFile -Value "Accountstatus,UserName,FirstName,LastName,Email,Company" 448 | Write-S3Object -bucketname $bucket -File $newUserFile -key $template 449 | Remove-Item $newUserFile 450 | return 451 | } 452 | Copy-S3Object -LocalFile $localFileName -BucketName $bucket -Key $prefix 453 | # write-Output "Downloaded UserList.csv to $localFileName" 454 | 455 | # Compare the file hash with the last version of downloaded file and only Process if they are different. 456 | $hashSrc = Get-FileHash $localFileName -Algorithm "SHA256" 457 | if (Test-Path -Path $oldhashfile ) { 458 | $hashDest = Get-FileHash $oldhashfile -Algorithm "SHA256" 459 | } 460 | 461 | # Compare the hashes & note this in the log 462 | If ($hashSrc.Hash -eq $hashDest.Hash) 463 | { 464 | write-Output "File Contents Unchanged" 465 | return 466 | } 467 | 468 | # Store the data from NewUsersList.csv in the $ADUsers variable 469 | 470 | $ADUsers = Import-Csv $localFileName -Delimiter "," 471 | 472 | $admin_user = Get-SECSecretValue -SecretId "${wsADSecret}" -Select SecretString | ConvertFrom-Json | Select-object -ExpandProperty username -ErrorAction Stop -ErrorVariable +ErrVar 473 | $password = Get-SECSecretValue -SecretId "${wsADSecret}" -Select SecretString | ConvertFrom-Json | Select-object -ExpandProperty password -ErrorAction Stop -ErrorVariable +ErrVar 474 | $spassword = ConvertTo-SecureString -String $password -AsPlainText -Force 475 | $cred = New-Object System.Management.Automation.PSCredential ($admin_user,$spassword) 476 | 477 | 478 | if (Get-ADOrganizationalUnit -Filter "Name -eq 'WorkspaceUsers'") { 479 | Write-Output("OU WorkspaceUsers already exists") 480 | } 481 | else #OU workspaces does not exist so create 482 | { 483 | Write-Output("OU Workspace Users Does Not exist - Creating ... ") 484 | New-ADOrganizationalUnit -Name "WorkspaceUsers" -Path $BaseDN -Credential $cred -ErrorAction Stop -ErrorVariable +ErrVar 485 | } 486 | 487 | 488 | # Loop through each row containing user details in the CSV file 489 | foreach ($User in $ADUsers) { 490 | #Read user data from each field in each row and assign the data to a variable as below 491 | $status = $User.accountstatus 492 | $username = $User.username 493 | $firstname = $User.firstname 494 | $lastname = $User.lastname 495 | $email = $User.email 496 | $company = $User.company 497 | 498 | # Check to see if the user already exists in AD 499 | if ($status -eq "A") { 500 | if (Get-ADUser -F { SamAccountName -eq $username }) { 501 | # If user does exist, give a warning 502 | Write-Warning "A user account with username $username already exists in Active Directory." 503 | } 504 | else { 505 | # User does not exist then proceed to create the new user account 506 | try{ 507 | Write-Output "Creating AD account for user: $username" 508 | #create random password for user 509 | $userpassword=Get-SecRandomPassword -ErrorAction Stop -ErrorVariable +ErrVar 510 | $secretId = "ws.users." + $username 511 | Write-Output "secret ID: $secretId" 512 | 513 | #create new secret for user 514 | $TagObject = New-Object Amazon.SecretsManager.Model.Tag -Property @{Key="UserId";Value=$username} 515 | 516 | Write-Output "Creating User Secret for user $username " 517 | New-SECSecret -Name $secretId -SecretString $userpassword -Description "AD User Account Details" -Tag $TagObject -ErrorAction Stop -ErrorVariable +ErrVar 518 | Write-Output "Created New Secret for : $secretId " 519 | } 520 | Catch{ 521 | Write-Error "Error creating Secret for User: $username $_.InvocationInfo.MyCommand.Name $_.ErrorDetails.Message $_.CategoryInfo.ToString()" 522 | } 523 | 524 | #get The password for the user from Secrects Manager and use it to create Account 525 | $pw = get-SecSecretvalue -secretId $secretId -ErrorAction Stop -ErrorVariable +ErrVar 526 | 527 | try { 528 | New-ADUser ` 529 | -Credential $cred ` 530 | -SamAccountName $username ` 531 | -Name "$firstname $lastname" ` 532 | -GivenName $firstname ` 533 | -Surname $lastname ` 534 | -Description "User $username - Auto Created by AWS AD UserManagement Process" ` 535 | -Enabled $True ` 536 | -DisplayName "$lastname, $firstname" ` 537 | -Path $workspaceUsersOU ` 538 | -Company $company ` 539 | -EmailAddress $email ` 540 | -AccountPassword (ConvertTo-secureString $pw.secretstring -AsPlainText -Force) -ChangePasswordAtLogon $False ` 541 | -ErrorAction Stop -ErrorVariable +ErrVar 542 | } 543 | Catch{ 544 | Write-Error "Error creating username $username $_.InvocationInfo.MyCommand.Name $_.ErrorDetails.Message $_.CategoryInfo.ToString() " 545 | } 546 | } #User does not exist 547 | } # if status A block 548 | else { #status = 'Deactivate' 549 | $inactiveOU=Get-ADOrganizationalUnit -Filter "Name -eq 'InactiveWSUsers'" | select-Object distinguishedName 550 | if($InactiveOU) #Disabled OU exists 551 | { 552 | $userEnabled = Get-ADUser -Filter {samaccountname -eq $username} | select-Object Enabled 553 | if ($userEnabled.enabled -eq $true) { 554 | try { 555 | Get-ADUser -F { SamAccountName -eq $username } | Disable-ADAccount -credential $cred -ErrorAction Stop -ErrorVariable +ErrVar 556 | Get-ADUser -F { SamAccountName -eq $username } | Move-ADObject -TargetPath $InactiveOU.DistinguishedName -credential $cred -ErrorAction Stop -ErrorVariable +ErrVar 557 | } 558 | Catch{ 559 | Write-Error "Error Disabling User $username in Active Directory$_.InvocationInfo.MyCommand.Name $_.ErrorDetails.Message $_.CategoryInfo.ToString() " 560 | } 561 | } 562 | else { 563 | Write-Warning "User $username is already Disabled" 564 | } 565 | } 566 | else # Inactive Users group does not exist 567 | { #Create New AD group for Inactive accounts similar to the Users group and move the account there 568 | try { 569 | Write-Output "Creating New OU for Inactive Users" 570 | New-ADOrganizationalUnit -Name "InactiveWSUsers" -Path $BaseDN -Credential $cred -ErrorAction Stop -ErrorVariable +ErrVar 571 | $inactiveOU=Get-ADOrganizationalUnit -Filter "Name -eq 'InactiveWSUsers'" | select-Object distinguishedName 572 | Write-Output "Disabling User $username" 573 | Get-ADUser -F { SamAccountName -eq $username } | Disable-ADAccount -credential $cred 574 | Get-ADUser -F { SamAccountName -eq $username } | Move-ADObject -TargetPath $InactiveOU.DistinguishedName -credential $cred -ErrorAction Stop -ErrorVariable +ErrVar 575 | 576 | } 577 | Catch{ 578 | Write-Error "Error Creating Inactive OU /disabling $username in Active Directory$_.InvocationInfo.MyCommand.Name $_.ErrorDetails.Message $_.CategoryInfo.ToString() " 579 | } 580 | } 581 | } 582 | } 583 | 584 | #If process ran successfully - Rename the file to "old" 585 | If ($ErrVar) { 586 | write-Output "Errors occured during processing of File - Exiting with Error Status" 587 | } 588 | else { 589 | write-Output "Successfully Processed File" 590 | #Move the processed file to the archive folder 591 | $processedFileName = "userlifecycle/archive/" + [system.io.path]::GetFileNameWithoutExtension($key)+ "processed" + [DateTime]::Now.ToString("yyyyMMddHHmmss") + ".csv" 592 | Write-S3Object -bucketname $bucket -File $LocalFileName -key $processedFileName 593 | #Now rename the LocalFile 594 | Move-Item -Path $LocalFileName -Destination $oldhashfile -Force 595 | } 596 | #Return Success 597 | Return 598 | 599 | workingDirectory: 600 | - "" 601 | executionTimeout: 602 | - "600" 603 | 604 | MaxConcurrency: 1 605 | MaxErrors: 1 606 | Priority: 10 607 | DependsOn: 608 | - MaintenanceWindow 609 | 610 | # Create role for Lambda compare function 611 | LambdaCompareRole: 612 | Type: AWS::IAM::Role 613 | Properties: 614 | RoleName: 'ws-automation-lambda-compare-role' 615 | AssumeRolePolicyDocument: 616 | Version: '2012-10-17' 617 | Statement: 618 | - 619 | Effect: 'Allow' 620 | Principal: 621 | Service: 622 | - 'lambda.amazonaws.com' 623 | Action: 624 | - 'sts:AssumeRole' 625 | 626 | # Create policy and attaches to the role for Lambda compare function 627 | LambdaCompareRolePolicy: 628 | Type: AWS::IAM::Policy 629 | Properties: 630 | PolicyName: 'ws-automation-lambda-compare-role-policy' 631 | PolicyDocument: 632 | Version: '2012-10-17' 633 | Statement: 634 | - 635 | Effect: 'Allow' 636 | Action: 637 | - 'logs:CreateLogGroup' 638 | - 'logs:CreateLogStream' 639 | - 'logs:PutLogEvents' 640 | - 'workspaces:CreateWorkspaces' 641 | - 'workspaces:TerminateWorkspaces' 642 | - 'workspaces:DescribeWorkspaces' 643 | Resource: '*' 644 | - 645 | Effect: 'Allow' 646 | Action: 647 | - 's3:PutObject' 648 | - 's3:GetObject' 649 | Resource: !Sub '${UsersBucket.Arn}/*' 650 | Roles: 651 | - !Ref LambdaCompareRole 652 | 653 | # Create Lambda compare function 654 | LambdaCompare: 655 | Type: AWS::Lambda::Function 656 | Properties: 657 | FunctionName: 'ws-automation-lambda-compare' 658 | Handler: 'index.lambda_handler' 659 | Role: !Sub '${LambdaCompareRole.Arn}' 660 | Code: 661 | ZipFile: 662 | | 663 | import csv 664 | import logging 665 | import os 666 | import boto3 667 | s3_client = boto3.client('s3') 668 | ws_client = boto3.client('workspaces') 669 | DIRECTORY_ID = os.getenv('DIRECTORY_ID') 670 | BUNDLE_ID = os.getenv('BUNDLE_ID',) 671 | RUNNING_MODE = 'AUTO_STOP' 672 | logging.basicConfig(format='%(asctime)s [%(levelname)+8s]%(module)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S') 673 | logger = logging.getLogger(__name__) 674 | logger.setLevel(getattr(logging, os.getenv('LOG_LEVEL', 'INFO'))) 675 | # --- Main handler --- 676 | def lambda_handler(event, context): 677 | bucket = event['Records'][0]['s3']['bucket']['name'] 678 | key = event['Records'][0]['s3']['object']['key'] 679 | # Get CSV file from S3 and transform it into JSON 680 | csv_object = s3_client.get_object(Bucket=bucket, Key=key) 681 | csv_users = csv.reader(csv_object['Body'].read().decode('utf-8').splitlines()) 682 | ad_users = set() 683 | for item in csv_users: 684 | if item: 685 | logger.debug('Adding user: {}'.format(item[0])) 686 | ad_users.add(item[0]) 687 | # Get current workspaces 688 | response = ws_client.describe_workspaces() 689 | workspaces = response['Workspaces'] 690 | current_ws = set() 691 | for workspace in workspaces: 692 | current_ws.add(workspace['UserName']) 693 | # If user is present in Ad user list but not in WorkSpaces list, terminate WorkSpace 694 | users_to_add = ad_users - current_ws 695 | logger.debug('Users to add: {}'.format(users_to_add)) 696 | for user in users_to_add: 697 | try: 698 | logger.info('Creating Workspaces for user: {}'.format(user)) 699 | create_ws = ws_client.create_workspaces( 700 | Workspaces=[ 701 | { 702 | 'DirectoryId': DIRECTORY_ID, 703 | 'UserName': user, 704 | 'BundleId': BUNDLE_ID, 705 | 'WorkspaceProperties': { 706 | 'RunningMode': RUNNING_MODE, 707 | 'RunningModeAutoStopTimeoutInMinutes': 60, 708 | 'RootVolumeSizeGib': 80, 709 | 'UserVolumeSizeGib': 50, 710 | 'ComputeTypeName': 'STANDARD' 711 | } 712 | } 713 | ] 714 | ) 715 | except Exception as e: 716 | logger.error('Unable to Create Workspaces') 717 | logger.debug('Error: {}'.format(e)) 718 | # If user is present in WorkSpaces list but not in AD user list, terminate WorkSpace 719 | ws_to_terminate = current_ws - ad_users 720 | logger.debug('Workspaces to terminate: {}'.format(ws_to_terminate)) 721 | for user in ws_to_terminate: 722 | try: 723 | logger.info('Terminating Workspaces for user: {}'.format(user)) 724 | describe_ws = ws_client.describe_workspaces(DirectoryId=DIRECTORY_ID) 725 | for workspace in describe_ws['Workspaces']: 726 | if workspace['UserName'] == user: 727 | workspace_id = workspace['WorkspaceId'] 728 | terminate_ws = ws_client.terminate_workspaces( 729 | TerminateWorkspaceRequests=[ 730 | { 731 | 'WorkspaceId': workspace_id 732 | }, 733 | ]) 734 | except Exception as e: 735 | logger.error('Error executing describe_workspaces or terminate_workspaces') 736 | logger.debug('Error: {}'.format(e)) 737 | 738 | Runtime: 'python3.9' 739 | Timeout: 600 740 | Environment: 741 | Variables: 742 | DIRECTORY_ID: !Ref DirectoryID 743 | BUNDLE_ID: !Ref BundleID 744 | 745 | # Creates the Lambda Compare function permission for the S3 bucket 746 | LambdaComparePermission: 747 | Type: AWS::Lambda::Permission 748 | Properties: 749 | FunctionName: !Ref LambdaCompare 750 | Action: 'lambda:InvokeFunction' 751 | Principal: 's3.amazonaws.com' 752 | SourceAccount: !Ref 'AWS::AccountId' 753 | SourceArn: !Sub 'arn:${AWS::Partition}:s3:::${UsersBucketName}' 754 | 755 | # Resource Outputs 756 | Outputs: 757 | 758 | StackName: 759 | Description: Stack name. 760 | Value: !Sub '${AWS::StackName}' 761 | 762 | UsersBucket: 763 | Description: S3 bucket name. 764 | Value: !Ref UsersBucket 765 | 766 | MaintenanceWindowRole: 767 | Description: Maintenance Window Role name. 768 | Value: !Ref MaintenanceWindowRole 769 | 770 | MaintenanceWindow: 771 | Description: Systems Manager Maintenance Window ID. 772 | Value: !Ref MaintenanceWindow 773 | 774 | MaintenanceWindowTask: 775 | Description: Maintenance Window Task ID. 776 | Value: !Ref MaintenanceWindowTask 777 | 778 | LambdaCompareRole: 779 | Description: Lambda Compare Role name. 780 | Value: !Ref LambdaCompareRole 781 | 782 | LambdaCompareRolePolicy: 783 | Description: Lambda Compare Policy ID. 784 | Value: !Ref LambdaCompareRolePolicy 785 | 786 | LambdaCompare: 787 | Description: Lambda Compare function name. 788 | Value: !Ref LambdaCompare --------------------------------------------------------------------------------