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/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/modules/delegated_account/README.md b/modules/delegated_account/README.md index 26634fe..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 | | ------------- | ----------- | -------- | ----- |-------- | -| `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 | +| `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` |
{| +| `operations_alternate_contact` |
name = "Finance Department"
title = "Finance Team"
email_address = "aws-billing@basefarm.com"
phone_number = "+47 4000 4100"
}
{| +| `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 9acb344..0ff18dd 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" @@ -28,7 +30,11 @@ data "aws_iam_policy_document" "account_management_policy" { "account:GetAlternateContact", "account:PutAlternateContact", "account:DeleteAlternateContact", + "account:GetContactInformation", + "account:PutContactInformation", "organizations:ListAccounts", + "organizations:ListParents", + "organizations:ListTagsForResource" ] resources = ["*"] condition { @@ -74,6 +80,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 +88,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/lambda/alternate-contact.py b/modules/delegated_account/lambda/alternate-contact.py index cbfaafd..6921dca 100644 --- a/modules/delegated_account/lambda/alternate-contact.py +++ b/modules/delegated_account/lambda/alternate-contact.py @@ -1,62 +1,147 @@ import os import re - +import json import boto3 from botocore.exceptions import ClientError +from functools import cache + + +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')) -ORG_CLIENT = boto3.client("organizations") -ACCOUNT_CLIENT = boto3.client("account") +MANAGEMENT_ACCOUNT_ID = os.environ.get('management_account_id') -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") +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: - 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"], - ) - - except ClientError as error: - FAILED_ACCOUNTS.append(accountId) - print(error) + 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"] + except ClientError as error: + FAILED_ACCOUNTS.append(accountId) + raise error # Need to abort, do not want to continue with missing account name + +def update_alternate_contact(accountId): + optional_contacts = get_contacts(ORG_CLIENT, accountId) + + for type, contact in ALTERNATE_CONTACTS.items(): + try: + 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: + 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 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 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: - put_alternate_contact(account["Id"]) - - return ("Completed! Failed Accounts: ", FAILED_ACCOUNTS) + 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/main.tf b/modules/delegated_account/main.tf index a481b1a..b4b6b44 100644 --- a/modules/delegated_account/main.tf +++ b/modules/delegated_account/main.tf @@ -30,10 +30,11 @@ 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 - management_account_id = var.management_account_id + 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 } } @@ -72,20 +73,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 = <
name = "Operations Center"
title = "Operations Center"
email_address = "support@basefarm-orange.com"
phone_number = "+47 4001 3123"
}