Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PCE-89 Update company name and invoice contact information in AWS #3

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions README.md

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the Usage section should be updated...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I think I must refactor the deployment in https://github.com/basefarm/bf-internal-security/tree/main/account. Move the contact administration to the master account.

This way we do not actually need the "delegated admin" support, thus there will be only one module, and usage must be updated accordingly.

Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
30 changes: 23 additions & 7 deletions modules/delegated_account/README.md

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the override tags be documented (tds:contact:*)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course :-) Will add

Original file line number Diff line number Diff line change
Expand Up @@ -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` |<pre>{<br> name = "Finance Department"<br> title = "Finance Team"<br> email_address = "aws-billing@basefarm.com"<br> phone_number = "+47 4000 4100"<br>}</pre>|
| `operations_alternate_contact` |<pre>{<br> name = "Operations Center"<br> title = "Operations Center"<br> email_address = "support@basefarm-orange.com"<br> phone_number = "+47 4001 3123"<br>}</pre>|
| `security_alternate_contact` |<pre>{<br> name = "Operations Center"<br> title = "Operations Center"<br> email_address = "support@basefarm-orange.com"<br> phone_number = "+47 4001 3123"<br>}</pre>|




## Outputs

Expand All @@ -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 |
11 changes: 9 additions & 2 deletions modules/delegated_account/data.tf
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -74,19 +80,20 @@ 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"
actions = [
"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 {
Expand Down
181 changes: 133 additions & 48 deletions modules/delegated_account/lambda/alternate-contact.py
Original file line number Diff line number Diff line change
@@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this pass can be removed

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one too (pass)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, was in the original code.


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)
Loading