From a508782a497c7453fd3eb75fe8f5caec62c8616f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20R=C3=B8geberg?= Date: Tue, 21 May 2024 13:59:56 +0200 Subject: [PATCH 1/5] PCD-89 fmt --- main.tf | 2 +- providers.tf | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/main.tf b/main.tf index e3286ee..6645f8f 100644 --- a/main.tf +++ b/main.tf @@ -8,7 +8,7 @@ module "delegated_admin_account" { security_alternate_contact = var.security_alternate_contact operations_alternate_contact = var.operations_alternate_contact billing_alternate_contact = var.billing_alternate_contact - invoke_lambda = var.invoke_lambda + invoke_lambda = var.invoke_lambda tags = var.tags } diff --git a/providers.tf b/providers.tf index d20684e..af96100 100644 --- a/providers.tf +++ b/providers.tf @@ -1,14 +1,14 @@ provider "aws" { - region = var.region - alias = "delegated_account" + region = var.region + alias = "delegated_account" assume_role { role_arn = "arn:aws:iam:::role/" } } provider "aws" { - region = var.region - alias = "mgmt_account" + region = var.region + alias = "mgmt_account" assume_role { role_arn = "arn:aws:iam:::role/" } From 252f42df886e4c945d83d0e74b206b6b6b5c7d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20R=C3=B8geberg?= Date: Tue, 21 May 2024 14:08:04 +0200 Subject: [PATCH 2/5] PCE-89 Made deployment into master account possible --- modules/delegated_account/README.md | 2 +- modules/delegated_account/data.tf | 7 +++++-- modules/delegated_account/main.tf | 12 +++++++----- modules/delegated_account/outputs.tf | 2 +- modules/delegated_account/variables.tf | 11 ++++++++++- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/modules/delegated_account/README.md b/modules/delegated_account/README.md index 26634fe..cc58d5a 100644 --- a/modules/delegated_account/README.md +++ b/modules/delegated_account/README.md @@ -29,11 +29,11 @@ All variable details can be found in the [variables.tf](./variables.tf) file. | Variable Name | Description | Type | Default | Required | | ------------- | ----------- | -------- | ----- |-------- | -| `management_account_id` | The account ID of the AWS Organizations Management account. | `string` | | Yes | | `alternate_contact_type` | The alternate contact details. Valid values are: SECURITY, BILLING, OPERATIONS | `map(string)`| {} | Yes | | `alternate_contact_role` | The AWS IAM role name that will be given to the AWS Lambda execution role | `string` | aws-alternate-contact-iam-role | Yes | | `alternate_contact_policy` | The name that will be given to the Lambda execution IAM policy | `string` | aws-alternate-contact-iam-policy | Yes | | `lambda_function_name` | The name of the AWS Lambda function | `string` | aws-alternate-contact | Yes | +| `management_account_id` | The account ID of the AWS Organizations Management account. | `string` | | Not if standalone | | `log_group_retention` | The number of days you want to retain log events in the specified log group | `number` | 60 | No | | `reserved_concurrent_executions` | The amount of reserved concurrent executions for this Lambda Function | `number` | -1 | No | | `event_rule_name` | The name of the EventBridge Rule to trigger the AWS Lambda function | `string` | aws-alternate-contact-rule | Yes | diff --git a/modules/delegated_account/data.tf b/modules/delegated_account/data.tf index 9acb344..0ceeb8f 100644 --- a/modules/delegated_account/data.tf +++ b/modules/delegated_account/data.tf @@ -1,5 +1,7 @@ data "aws_organizations_organization" "account_info" {} +data "aws_caller_identity" "this" {} + data "archive_file" "lambda_zip" { type = "zip" output_path = "${path.module}/lambda/alternate-contact.zip" @@ -74,6 +76,7 @@ data "aws_iam_policy_document" "account_management_policy" { } data "aws_iam_policy_document" "aws_alternate_contact_bus" { + count = var.standalone ? 0 : 1 statement { sid = "ManagementAccountAccess" effect = "Allow" @@ -81,12 +84,12 @@ data "aws_iam_policy_document" "aws_alternate_contact_bus" { "events:PutEvents", ] resources = [ - "${aws_cloudwatch_event_bus.aws_alternate_contact_bus.arn}" + "${aws_cloudwatch_event_bus.aws_alternate_contact_bus[0].arn}" ] principals { type = "AWS" identifiers = [ - "arn:aws:iam::${var.management_account_id}:root" + "arn:aws:iam::${local.management_account_id}:root" ] } condition { diff --git a/modules/delegated_account/main.tf b/modules/delegated_account/main.tf index a481b1a..d3eddbf 100644 --- a/modules/delegated_account/main.tf +++ b/modules/delegated_account/main.tf @@ -33,7 +33,7 @@ resource "aws_lambda_function" "alternate_contact_lambda" { security_alternate_contact = var.security_alternate_contact billing_alternate_contact = var.billing_alternate_contact operations_alternate_contact = var.operations_alternate_contact - management_account_id = var.management_account_id + management_account_id = local.management_account_id } } @@ -72,20 +72,22 @@ resource "aws_cloudwatch_log_group" "alternate_contact_loggroup" { } resource "aws_cloudwatch_event_bus" "aws_alternate_contact_bus" { + count = var.standalone ? 0 : 1 name = var.aws_alternate_contact_bus tags = var.tags } resource "aws_cloudwatch_event_bus_policy" "aws_alternate_contact_bus" { - policy = data.aws_iam_policy_document.aws_alternate_contact_bus.json - event_bus_name = aws_cloudwatch_event_bus.aws_alternate_contact_bus.name + count = var.standalone ? 0 : 1 + policy = data.aws_iam_policy_document.aws_alternate_contact_bus[0].json + event_bus_name = aws_cloudwatch_event_bus.aws_alternate_contact_bus[0].name } resource "aws_cloudwatch_event_rule" "event_rule_cross_account" { name = var.event_rule_name description = var.event_rule_description - event_bus_name = aws_cloudwatch_event_bus.aws_alternate_contact_bus.name + event_bus_name = var.standalone ? "default" : aws_cloudwatch_event_bus.aws_alternate_contact_bus[0].name tags = var.tags event_pattern = < Date: Tue, 21 May 2024 14:09:46 +0200 Subject: [PATCH 3/5] PCE-89 Added update of primary contact info and prep for deployment across all customer master accounts --- README.md | 46 ++++++++- modules/delegated_account/README.md | 28 ++++-- modules/delegated_account/data.tf | 2 + .../lambda/alternate-contact.py | 74 +++++++++++--- modules/delegated_account/main.tf | 7 +- modules/delegated_account/outputs.tf | 7 +- modules/delegated_account/variables.tf | 98 +++++++++++++++++-- 7 files changed, 228 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 7daa81c..c3f0d76 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,56 @@ ## Description -This Terraform module enables the programmatic implementation and management of AWS account alternate contacts on member accounts in [AWS Organizations](https://aws.amazon.com/organizations/). For additional implementation guidance, refer to the blog post on how to [manage AWS account alternate contacts with Terraform](https://amazon.com/blogs/mt/manage-aws-account-alternate-contacts-with-terraform/). +This Terraform module enables the programmatic implementation and management of AWS account primary and alternate contacts on member accounts in [AWS Organizations](https://aws.amazon.com/organizations/). For additional implementation guidance, refer to the blog post on how to [manage AWS account alternate contacts with Terraform](https://amazon.com/blogs/mt/manage-aws-account-alternate-contacts-with-terraform/). ## Features -* Programmatically manage billing, operations, or security contact across all of your accounts. +* Programmatically manage primary, billing, operations, or security contact across all of your accounts. * Easily set the alternate contacts on new accounts that you create or add to AWS Organizations. +## Deployment considerations + +This module has been extended to support deployment in the AWS Organizations master account. + +To support this you need to enable account management in the master account. This is done +through setting the `aws_service_access_principals` for the `bf_billing_master` module: + +```hcl +module "master_billing" { + source = "git@github.com:basefarm/bootstrap-accounts//modules/bf_billing_master?ref=v1.0.28" + + customer_prefix = var.customer_prefix + billing_role_arn = module.master.billing["billing_role_arn"] + aws_service_access_principals = [ + "account.amazonaws.com" + ] + tags = var.tags + providers = { + aws = aws.master + aws.us-east-1 = aws.master-us-east-1 + } +} +``` + +For the deployment of this module you do: + +```hcl +module "alternate_contacts" { + source = "git@github.com:basefarm/aws-account-alternate-contact-with-terraform//modules/delegated_account?ref=1.1.0" + tags = var.tags + providers = { + aws = aws.master + } +} +``` + +You do not deploy the `management_account` sub-module at all. + +The primary and alternate contacts should be the same for all accounts. +If you need to change the contact information, consider changing the +default values in the `delegated_account` sub-module. + ## Prerequisites The following prerequisites are required to deploy the solution: diff --git a/modules/delegated_account/README.md b/modules/delegated_account/README.md index cc58d5a..9eac15a 100644 --- a/modules/delegated_account/README.md +++ b/modules/delegated_account/README.md @@ -29,19 +29,34 @@ All variable details can be found in the [variables.tf](./variables.tf) file. | Variable Name | Description | Type | Default | Required | | ------------- | ----------- | -------- | ----- |-------- | -| `alternate_contact_type` | The alternate contact details. Valid values are: SECURITY, BILLING, OPERATIONS | `map(string)`| {} | Yes | -| `alternate_contact_role` | The AWS IAM role name that will be given to the AWS Lambda execution role | `string` | aws-alternate-contact-iam-role | Yes | -| `alternate_contact_policy` | The name that will be given to the Lambda execution IAM policy | `string` | aws-alternate-contact-iam-policy | Yes | -| `lambda_function_name` | The name of the AWS Lambda function | `string` | aws-alternate-contact | Yes | | `management_account_id` | The account ID of the AWS Organizations Management account. | `string` | | Not if standalone | +| `alternate_contact_role` | The AWS IAM role name that will be given to the AWS Lambda execution role | `string` | aws-alternate-contact-iam-role | No | +| `alternate_contact_policy` | The name that will be given to the Lambda execution IAM policy | `string` | aws-alternate-contact-iam-policy | No | +| `lambda_function_name` | The name of the AWS Lambda function | `string` | aws-alternate-contact | No | | `log_group_retention` | The number of days you want to retain log events in the specified log group | `number` | 60 | No | | `reserved_concurrent_executions` | The amount of reserved concurrent executions for this Lambda Function | `number` | -1 | No | -| `event_rule_name` | The name of the EventBridge Rule to trigger the AWS Lambda function | `string` | aws-alternate-contact-rule | Yes | +| `event_rule_name` | The name of the EventBridge Rule to trigger the AWS Lambda function | `string` | aws-alternate-contact-rule | No | | `event_rule_description` | The description of the EventBridge rule | `string` | EventBridge rule to trigger the alternate contact Lambda function | No | -| `aws_alternate_contact_bus` | The name of the custom event bus | `string` | aws-alternate-contact | Yes | +| `aws_alternate_contact_bus` | The name of the custom event bus | `string` | aws-alternate-contact | No | | `invoke_lambda` | Controls if Lambda function should be invoked | `bool` | | No | +| `primary_contact` | Primary contact information | `object` | Company default (see below) | No | +| `billing_alternate_contact` | The alternate contact details. | `object` | Company default (see below) | No | +| `operations_alternate_contact`| The alternate contact details. | `object` | Company default (see below) | No | +| `security_alternate_contact`| The alternate contact details. | `object` | Company default (see below) | No | +| `standalone` | Standalone deployment, no delegated admin account | `bool` | true | No | | `tags` | A map of tags to assign to the resource | `map(string)`| | No | +The `*_alternate_contacts` variables have defined defaults matching the company requirements. If you need to change them, consider changing the module defaults. + +| Variable Name | Default | +| ------------- | --------| +| `primary_contact` | See (./variables.tf) | +| `billing_alternate_contact` |
{
name = "Finance Department"
title = "Finance Team"
email_address = "aws-billing@basefarm.com"
phone_number = "+47 4000 4100"
}
| +| `operations_alternate_contact` |
{
name = "Operations Center"
title = "Operations Center"
email_address = "support@basefarm-orange.com"
phone_number = "+47 4001 3123"
}
| +| `security_alternate_contact` |
{
name = "Operations Center"
title = "Operations Center"
email_address = "support@basefarm-orange.com"
phone_number = "+47 4001 3123"
}
| + + + ## Outputs @@ -53,3 +68,4 @@ All output details can be found in [aws-account-alternate-contact-with-terraform | `event_rule_cross_account`| The Amazon Resource Name (ARN) of the EventBridge rule | | `alternate_contact_role` | The Amazon Resource Name (ARN) specifying the Lambda IAM role | | `aws_lambda_function` | The Amazon Resource Name (ARN) identifying the Lambda Function | +| `failed_accounts` | List of accounts where lambda execution failed | diff --git a/modules/delegated_account/data.tf b/modules/delegated_account/data.tf index 0ceeb8f..22d1890 100644 --- a/modules/delegated_account/data.tf +++ b/modules/delegated_account/data.tf @@ -30,6 +30,8 @@ data "aws_iam_policy_document" "account_management_policy" { "account:GetAlternateContact", "account:PutAlternateContact", "account:DeleteAlternateContact", + "account:GetContactInformation", + "account:PutContactInformation", "organizations:ListAccounts", ] resources = ["*"] diff --git a/modules/delegated_account/lambda/alternate-contact.py b/modules/delegated_account/lambda/alternate-contact.py index cbfaafd..e1dba15 100644 --- a/modules/delegated_account/lambda/alternate-contact.py +++ b/modules/delegated_account/lambda/alternate-contact.py @@ -1,6 +1,6 @@ import os import re - +import json import boto3 from botocore.exceptions import ClientError @@ -11,6 +11,7 @@ BILL_ALTERNATE_CONTACTS = os.environ.get("operations_alternate_contact") OPS_ALTERNATE_CONTACTS = os.environ.get("billing_alternate_contact") MANAGEMENT_ACCOUNT_ID = os.environ.get("management_account_id") +PRIMARY_CONTACT = json.loads(os.environ.get("primary_contact")) CONTACTS = [] FAILED_ACCOUNTS = [] @@ -26,7 +27,6 @@ def list_accounts(client): response = None return accounts - def parse_contact_types(): CONTACT_LIST = [] for contact in [SEC_ALTERNATE_CONTACTS, BILL_ALTERNATE_CONTACTS, OPS_ALTERNATE_CONTACTS]: @@ -34,29 +34,75 @@ def parse_contact_types(): list_to_dict = {CONTACT_LIST[i]: CONTACT_LIST[i + 1] for i in range(0, len(CONTACT_LIST), 2)} CONTACTS.append(list_to_dict) - def put_alternate_contact(accountId): for contact in CONTACTS: try: - response = ACCOUNT_CLIENT.put_alternate_contact( - AccountId=accountId, - AlternateContactType=contact["CONTACT_TYPE"], - EmailAddress=contact["EMAIL_ADDRESS"], - Name=contact["CONTACT_NAME"], - PhoneNumber=contact["PHONE_NUMBER"], - Title=contact["CONTACT_TITLE"], - ) - + if accountId != MANAGEMENT_ACCOUNT_ID: + response = ACCOUNT_CLIENT.put_alternate_contact( + AccountId=accountId, + AlternateContactType=contact["CONTACT_TYPE"], + EmailAddress=contact["EMAIL_ADDRESS"], + Name=contact["CONTACT_NAME"], + PhoneNumber=contact["PHONE_NUMBER"], + Title=contact["CONTACT_TITLE"], + ) + else: + response = ACCOUNT_CLIENT.put_alternate_contact( + AlternateContactType=contact["CONTACT_TYPE"], + EmailAddress=contact["EMAIL_ADDRESS"], + Name=contact["CONTACT_NAME"], + PhoneNumber=contact["PHONE_NUMBER"], + Title=contact["CONTACT_TITLE"], + ) except ClientError as error: FAILED_ACCOUNTS.append(accountId) print(error) pass +# Full name is a mandatory parameter and is uniq to every account +def get_full_name(accountId): + try: + if accountId != MANAGEMENT_ACCOUNT_ID: + response = ACCOUNT_CLIENT.get_contact_information( + AccountId=accountId + ) + else: + response = ACCOUNT_CLIENT.get_contact_information() + + return response["ContactInformation"]["FullName"] + except ClientError as error: + FAILED_ACCOUNTS.append(accountId) + print(error) + pass + return None + +def update_primary_contact(accountId): + try: + contact = PRIMARY_CONTACT.copy() + contact["FullName"] = get_full_name(accountId) + if accountId != MANAGEMENT_ACCOUNT_ID: + response = ACCOUNT_CLIENT.put_contact_information( + AccountId = accountId, + ContactInformation=contact + ) + else: + response = ACCOUNT_CLIENT.put_contact_information( + ContactInformation=contact + ) + except ClientError as error: + FAILED_ACCOUNTS.append(accountId) + print(error) + pass def lambda_handler(event, context): parse_contact_types() for account in list_accounts(ORG_CLIENT): - if account["Status"] != "SUSPENDED" and account["Id"] != MANAGEMENT_ACCOUNT_ID: + if account["Status"] != "SUSPENDED": + print("Updating contact information for ", account["Id"]) put_alternate_contact(account["Id"]) + update_primary_contact(account["Id"]) + +# put_alternate_contact_master() +# update_primary_contact(MANAGEMENT_ACCOUNT_ID) - return ("Completed! Failed Accounts: ", FAILED_ACCOUNTS) + return (FAILED_ACCOUNTS) diff --git a/modules/delegated_account/main.tf b/modules/delegated_account/main.tf index d3eddbf..b4b6b44 100644 --- a/modules/delegated_account/main.tf +++ b/modules/delegated_account/main.tf @@ -30,9 +30,10 @@ resource "aws_lambda_function" "alternate_contact_lambda" { tags = var.tags environment { variables = { - security_alternate_contact = var.security_alternate_contact - billing_alternate_contact = var.billing_alternate_contact - operations_alternate_contact = var.operations_alternate_contact + primary_contact = local.primary_contact + security_alternate_contact = local.security_alternate_contact + billing_alternate_contact = local.billing_alternate_contact + operations_alternate_contact = local.operations_alternate_contact management_account_id = local.management_account_id } } diff --git a/modules/delegated_account/outputs.tf b/modules/delegated_account/outputs.tf index a306a00..965d871 100644 --- a/modules/delegated_account/outputs.tf +++ b/modules/delegated_account/outputs.tf @@ -16,4 +16,9 @@ output "alternate_contact_role" { output "aws_lambda_function" { value = aws_lambda_function.alternate_contact_lambda.arn description = "The Amazon Resource Name (ARN) identifying the Lambda Function" -} \ No newline at end of file +} + +output "failed_accounts" { + description = "List of accounts where lambda execution failed" + value = jsondecode(data.aws_lambda_invocation.run[0].result) +} diff --git a/modules/delegated_account/variables.tf b/modules/delegated_account/variables.tf index 8b37b05..acfbe41 100644 --- a/modules/delegated_account/variables.tf +++ b/modules/delegated_account/variables.tf @@ -4,24 +4,80 @@ variable "management_account_id" { default = "" } -variable "security_alternate_contact" { - type = string - description = "The security alternate contact details." - default = "" locals { management_account_id = var.standalone ? data.aws_caller_identity.this.account_id : var.management_account_id } +variable "primary_contact" { + description = "Primary contact information" + type = object({ + AddressLine1 = string + City = string + CompanyName = string + CountryCode = string + PhoneNumber = string + PostalCode = string + StateOrRegion = string + WebsiteUrl = string + + }) + default = { + AddressLine1 = "Postboks 4488 Nydalen" + City = "Oslo" + CompanyName = "Orange Business Services AS" + CountryCode = "NO" + PhoneNumber = "+47 4000 4100" + PostalCode = "0403" + StateOrRegion = "Oslo" + WebsiteUrl = "https://cloud.orange-business.com/no/" + } +} variable "billing_alternate_contact" { - type = string description = "The alternate contact details." - default = "" + type = object({ + name = string + title = string + email_address = string + phone_number = string + }) + default = { + name = "Finance Department" + title = "Finance Team" + email_address = "aws-billing@basefarm.com" + phone_number = "+47 4000 4100" + } } variable "operations_alternate_contact" { - type = string description = "The alternate contact details." - default = "" + type = object({ + name = string + title = string + email_address = string + phone_number = string + }) + default = { + name = "Operations Center" + title = "Operations Center" + email_address = "support@basefarm-orange.com" + phone_number = "+47 4001 3123" + } +} + +variable "security_alternate_contact" { + description = "The security alternate contact details." + type = object({ + name = string + title = string + email_address = string + phone_number = string + }) + default = { + name = "Operations Center" + title = "Operations Center" + email_address = "support@basefarm-orange.com" + phone_number = "+47 4001 3123" + } } variable "alternate_contact_role" { @@ -85,9 +141,35 @@ variable "aws_alternate_contact_bus" { variable "invoke_lambda" { description = "Controls if Lambda function should be invoked" type = bool + default = true } variable "tags" { description = "A map of tags to assign to the resource" type = map(string) } + +locals { + billing_alternate_contact = format( + "CONTACT_TYPE=BILLING; EMAIL_ADDRESS=%s; CONTACT_NAME=%s; PHONE_NUMBER=%s; CONTACT_TITLE=%s", + var.billing_alternate_contact.email_address, + var.billing_alternate_contact.name, + var.billing_alternate_contact.phone_number, + var.billing_alternate_contact.title + ) + operations_alternate_contact = format( + "CONTACT_TYPE=OPERATIONS; EMAIL_ADDRESS=%s; CONTACT_NAME=%s; PHONE_NUMBER=%s; CONTACT_TITLE=%s", + var.operations_alternate_contact.email_address, + var.operations_alternate_contact.name, + var.operations_alternate_contact.phone_number, + var.operations_alternate_contact.title + ) + security_alternate_contact = format( + "CONTACT_TYPE=SECURITY; EMAIL_ADDRESS=%s; CONTACT_NAME=%s; PHONE_NUMBER=%s; CONTACT_TITLE=%s", + var.security_alternate_contact.email_address, + var.security_alternate_contact.name, + var.security_alternate_contact.phone_number, + var.security_alternate_contact.title + ) + primary_contact = jsonencode(var.primary_contact) +} From 5b08012a8100eb1c0f6f65236c95354d4509edf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20R=C3=B8geberg?= Date: Thu, 23 May 2024 13:19:20 +0200 Subject: [PATCH 4/5] PCE-89 Align default log retention with company policy --- modules/delegated_account/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/delegated_account/variables.tf b/modules/delegated_account/variables.tf index acfbe41..1a4cbc0 100644 --- a/modules/delegated_account/variables.tf +++ b/modules/delegated_account/variables.tf @@ -105,7 +105,7 @@ variable "log_group_retention" { Possible values are: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653, and 0 If you select 0, the events in the log group are always retained and never expire. EOT - default = 60 + default = 731 } variable "reserved_concurrent_executions" { From 96a75cc30aa8a9c98e9a916b2baa00f2be4da2f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20R=C3=B8geberg?= Date: Thu, 23 May 2024 13:20:16 +0200 Subject: [PATCH 5/5] PCE-89 Added support for AWS Organizations based tags on OUs and accounts for controlling alternate contact email --- modules/delegated_account/data.tf | 2 + .../lambda/alternate-contact.py | 205 +++++++++++------- modules/delegated_account/variables.tf | 90 ++++---- 3 files changed, 166 insertions(+), 131 deletions(-) diff --git a/modules/delegated_account/data.tf b/modules/delegated_account/data.tf index 22d1890..0ff18dd 100644 --- a/modules/delegated_account/data.tf +++ b/modules/delegated_account/data.tf @@ -33,6 +33,8 @@ data "aws_iam_policy_document" "account_management_policy" { "account:GetContactInformation", "account:PutContactInformation", "organizations:ListAccounts", + "organizations:ListParents", + "organizations:ListTagsForResource" ] resources = ["*"] condition { diff --git a/modules/delegated_account/lambda/alternate-contact.py b/modules/delegated_account/lambda/alternate-contact.py index e1dba15..6921dca 100644 --- a/modules/delegated_account/lambda/alternate-contact.py +++ b/modules/delegated_account/lambda/alternate-contact.py @@ -3,106 +3,145 @@ import json import boto3 from botocore.exceptions import ClientError +from functools import cache -ORG_CLIENT = boto3.client("organizations") -ACCOUNT_CLIENT = boto3.client("account") -SEC_ALTERNATE_CONTACTS = os.environ.get("security_alternate_contact") -BILL_ALTERNATE_CONTACTS = os.environ.get("operations_alternate_contact") -OPS_ALTERNATE_CONTACTS = os.environ.get("billing_alternate_contact") -MANAGEMENT_ACCOUNT_ID = os.environ.get("management_account_id") -PRIMARY_CONTACT = json.loads(os.environ.get("primary_contact")) +ORG_CLIENT = boto3.client('organizations') +ACCOUNT_CLIENT = boto3.client('account') + +ALTERNATE_CONTACTS = { + 'SECURITY': json.loads(os.environ.get('security_alternate_contact')), + 'OPERATIONS': json.loads(os.environ.get('operations_alternate_contact')), + 'BILLING': json.loads(os.environ.get('billing_alternate_contact')) +} +PRIMARY_CONTACT = json.loads(os.environ.get('primary_contact')) + +MANAGEMENT_ACCOUNT_ID = os.environ.get('management_account_id') + +CONTACT_TAGS = { + 'OPERATIONS': 'tds:contact:operations', + 'SECURITY': 'tds:contact:security' +} + +DEFAULT_CONTACT_EMAIL = 'support@basefarm-orange.com' -CONTACTS = [] FAILED_ACCOUNTS = [] def list_accounts(client): - response = client.list_accounts() - accounts = [] - while response: - accounts += response["Accounts"] - if "NextToken" in response: - response = client.list_accounts(NextToken = response["NextToken"]) - else: - response = None - return accounts - -def parse_contact_types(): - CONTACT_LIST = [] - for contact in [SEC_ALTERNATE_CONTACTS, BILL_ALTERNATE_CONTACTS, OPS_ALTERNATE_CONTACTS]: - CONTACT_LIST = re.split("=|; ", contact) - list_to_dict = {CONTACT_LIST[i]: CONTACT_LIST[i + 1] for i in range(0, len(CONTACT_LIST), 2)} - CONTACTS.append(list_to_dict) - -def put_alternate_contact(accountId): - for contact in CONTACTS: - try: - if accountId != MANAGEMENT_ACCOUNT_ID: - response = ACCOUNT_CLIENT.put_alternate_contact( - AccountId=accountId, - AlternateContactType=contact["CONTACT_TYPE"], - EmailAddress=contact["EMAIL_ADDRESS"], - Name=contact["CONTACT_NAME"], - PhoneNumber=contact["PHONE_NUMBER"], - Title=contact["CONTACT_TITLE"], - ) - else: - response = ACCOUNT_CLIENT.put_alternate_contact( - AlternateContactType=contact["CONTACT_TYPE"], - EmailAddress=contact["EMAIL_ADDRESS"], - Name=contact["CONTACT_NAME"], - PhoneNumber=contact["PHONE_NUMBER"], - Title=contact["CONTACT_TITLE"], - ) - except ClientError as error: - FAILED_ACCOUNTS.append(accountId) - print(error) - pass + response = client.list_accounts() + accounts = [] + while response: + accounts += response['Accounts'] + if "NextToken" in response: + response = client.list_accounts(NextToken = response['NextToken']) + else: + response = None + return accounts + +@cache +def list_parents(client, ChildId): + return client.list_parents( ChildId=ChildId) + +@cache +def list_tags_for_resource(client, ResourceId): + tags = {} + response = client.list_tags_for_resource( ResourceId = ResourceId ) + if 'Tags' in response: + tags = {t['Key']: t['Value'] for t in response['Tags']} + return tags + +def get_ous(client, account_id): + parent_list = [ account_id ] + response = list_parents(client, ChildId=account_id ) + while response['Parents'][0]['Type'] != 'ROOT': + parent_list.append(response['Parents'][0]['Id']) + response = list_parents(client, ChildId=response['Parents'][0]['Id']) + parent_list.append(response['Parents'][0]['Id']) + parent_list.reverse() + return parent_list + +def get_tag_contacts(client, resources): + contacts = { + 'SECURITY': DEFAULT_CONTACT_EMAIL, + 'OPERATIONS': DEFAULT_CONTACT_EMAIL + } + for ResourceId in resources: + tags = list_tags_for_resource(client, ResourceId ) + for tag in tags: + for key, value in CONTACT_TAGS.items(): + if tag.startswith(value): + contacts[key] = tags[tag] + return contacts + +def get_contacts(client, account_id): + return get_tag_contacts(client, get_ous(client, account_id)) # Full name is a mandatory parameter and is uniq to every account def get_full_name(accountId): try: - if accountId != MANAGEMENT_ACCOUNT_ID: - response = ACCOUNT_CLIENT.get_contact_information( - AccountId=accountId - ) - else: - response = ACCOUNT_CLIENT.get_contact_information() - - return response["ContactInformation"]["FullName"] + if accountId != MANAGEMENT_ACCOUNT_ID: + response = ACCOUNT_CLIENT.get_contact_information(AccountId=accountId) + else: + response = ACCOUNT_CLIENT.get_contact_information() + return response["ContactInformation"]["FullName"] except ClientError as error: - FAILED_ACCOUNTS.append(accountId) - print(error) - pass - return None + FAILED_ACCOUNTS.append(accountId) + raise error # Need to abort, do not want to continue with missing account name -def update_primary_contact(accountId): +def update_alternate_contact(accountId): + optional_contacts = get_contacts(ORG_CLIENT, accountId) + + for type, contact in ALTERNATE_CONTACTS.items(): try: - contact = PRIMARY_CONTACT.copy() - contact["FullName"] = get_full_name(accountId) - if accountId != MANAGEMENT_ACCOUNT_ID: - response = ACCOUNT_CLIENT.put_contact_information( - AccountId = accountId, - ContactInformation=contact - ) + account_contact = contact.copy() + if type in optional_contacts: + if optional_contacts[type] == '': + delete_params = { + 'AlternateContactType': type + } + if accountId != MANAGEMENT_ACCOUNT_ID: + delete_params['AccountId'] = accountId + print("Delete alternate contact ", type) + try: + ACCOUNT_CLIENT.delete_alternate_contact(**delete_params) + except ClientError as error: + if error.response['Error']['Code'] == 'ResourceNotFoundException': + pass + else: + raise error + pass + continue else: - response = ACCOUNT_CLIENT.put_contact_information( - ContactInformation=contact - ) + account_contact['EmailAddress'] = optional_contacts[type] + if accountId != MANAGEMENT_ACCOUNT_ID: + account_contact['AccountId'] = accountId + print("Setting alternate contact ", type, "to ", account_contact) + ACCOUNT_CLIENT.put_alternate_contact(**account_contact) except ClientError as error: FAILED_ACCOUNTS.append(accountId) print(error) pass -def lambda_handler(event, context): - parse_contact_types() - for account in list_accounts(ORG_CLIENT): - if account["Status"] != "SUSPENDED": - print("Updating contact information for ", account["Id"]) - put_alternate_contact(account["Id"]) - update_primary_contact(account["Id"]) - -# put_alternate_contact_master() -# update_primary_contact(MANAGEMENT_ACCOUNT_ID) +def update_primary_contact(accountId): + try: + contact = PRIMARY_CONTACT.copy() + contact["FullName"] = get_full_name(accountId) + print("Setting primary contact to ", contact) + if accountId != MANAGEMENT_ACCOUNT_ID: + ACCOUNT_CLIENT.put_contact_information( + AccountId = accountId, + ContactInformation=contact) + else: + ACCOUNT_CLIENT.put_contact_information(ContactInformation=contact) + except ClientError as error: + FAILED_ACCOUNTS.append(accountId) + print(error) + pass - return (FAILED_ACCOUNTS) +def lambda_handler(event, context): + for account in list_accounts(ORG_CLIENT): + if account["Status"] != "SUSPENDED": + print("Updating contact information for ", account["Id"]) + update_primary_contact(account["Id"]) + update_alternate_contact(account["Id"]) + return (FAILED_ACCOUNTS) diff --git a/modules/delegated_account/variables.tf b/modules/delegated_account/variables.tf index 1a4cbc0..96716a2 100644 --- a/modules/delegated_account/variables.tf +++ b/modules/delegated_account/variables.tf @@ -8,6 +8,8 @@ locals { management_account_id = var.standalone ? data.aws_caller_identity.this.account_id : var.management_account_id } +# The contact objects (primary, billing, security and operations) are +# all based on the API names for the keys variable "primary_contact" { description = "Primary contact information" type = object({ @@ -33,50 +35,60 @@ variable "primary_contact" { } } variable "billing_alternate_contact" { - description = "The alternate contact details." + description = "The billing alternate contact details." type = object({ - name = string - title = string - email_address = string - phone_number = string + Name = string + Title = string + EmailAddress = string + PhoneNumber = string + AlternateContactType = string }) default = { - name = "Finance Department" - title = "Finance Team" - email_address = "aws-billing@basefarm.com" - phone_number = "+47 4000 4100" + Name = "Finance Department" + Title = "Finance Team" + EmailAddress = "aws-billing@basefarm.com" + PhoneNumber = "+47 4000 4100" + AlternateContactType = "BILLING" } } variable "operations_alternate_contact" { - description = "The alternate contact details." + description = "The operations alternate contact details." type = object({ - name = string - title = string - email_address = string - phone_number = string + Name = string + Title = string + EmailAddress = string + PhoneNumber = string + AlternateContactType = string + }) default = { - name = "Operations Center" - title = "Operations Center" - email_address = "support@basefarm-orange.com" - phone_number = "+47 4001 3123" + Name = "Operations Center" + Title = "Operations Center" + EmailAddress = "support@basefarm-orange.com" + PhoneNumber = "+47 4001 3123" + AlternateContactType = "OPERATIONS" + } } variable "security_alternate_contact" { description = "The security alternate contact details." type = object({ - name = string - title = string - email_address = string - phone_number = string + Name = string + Title = string + EmailAddress = string + PhoneNumber = string + AlternateContactType = string + }) default = { - name = "Operations Center" - title = "Operations Center" - email_address = "support@basefarm-orange.com" - phone_number = "+47 4001 3123" + Name = "Operations Center" + Title = "Operations Center" + EmailAddress = "support@basefarm-orange.com" + PhoneNumber = "+47 4001 3123" + AlternateContactType = "SECURITY" + } } @@ -150,26 +162,8 @@ variable "tags" { } locals { - billing_alternate_contact = format( - "CONTACT_TYPE=BILLING; EMAIL_ADDRESS=%s; CONTACT_NAME=%s; PHONE_NUMBER=%s; CONTACT_TITLE=%s", - var.billing_alternate_contact.email_address, - var.billing_alternate_contact.name, - var.billing_alternate_contact.phone_number, - var.billing_alternate_contact.title - ) - operations_alternate_contact = format( - "CONTACT_TYPE=OPERATIONS; EMAIL_ADDRESS=%s; CONTACT_NAME=%s; PHONE_NUMBER=%s; CONTACT_TITLE=%s", - var.operations_alternate_contact.email_address, - var.operations_alternate_contact.name, - var.operations_alternate_contact.phone_number, - var.operations_alternate_contact.title - ) - security_alternate_contact = format( - "CONTACT_TYPE=SECURITY; EMAIL_ADDRESS=%s; CONTACT_NAME=%s; PHONE_NUMBER=%s; CONTACT_TITLE=%s", - var.security_alternate_contact.email_address, - var.security_alternate_contact.name, - var.security_alternate_contact.phone_number, - var.security_alternate_contact.title - ) - primary_contact = jsonencode(var.primary_contact) + billing_alternate_contact = jsonencode(var.billing_alternate_contact) + operations_alternate_contact = jsonencode(var.operations_alternate_contact) + security_alternate_contact = jsonencode(var.security_alternate_contact) + primary_contact = jsonencode(var.primary_contact) }