Over the years of using AWS, Ive invested a lot of effort into managing costs and maintaining a tidy account. However, I began creating new accounts and closing them once they were no longer needed. This approach has led to several challenges, including:
Complications in billing management
Inability to share credits
Challenges with quota management
Orphaned resources
A $1 charge for each account created
The need to create temporary emails and manage contact information
AWS Organization
AWS Organizations is a service from AWS designed to streamline the centralized management of accounts. It offers features such as account provisioning, centralized billing, access management, policy management, and enforcement of standards.
Organizational Unit
An Organizational Unit (OU) is a grouping of accounts that allows for the distribution of accounts based on specific contexts or needs. For example, you might create one OU for workload accounts (development, testing, production) and another for security or networking purposes.
This separation enables additional automation for the member accounts, such as bootstrapping or deploying infrastructure tailored to specific contexts.
AWS CDK and challenges
Onboarding a new organization using CDK initially appeared to be as straightforward as creating any other piece of infrastructure. However, as I began implementation, I encountered several gaps due to missing features in CloudFormation and the Dedicated Service API.
The AWS Organizations service requires trusted access, which is only available through the Organizations Service API.
Activating SSO with IAM Identity Center can only be done through a manual process in the console.
Creating accounts necessitates unique email addresses, which makes it frustrating to set up multiple Gmail or whatever accounts.
While setting up SES with Route 53 was relatively easy, the documentation was confusing and misleading.
Source Code
has generated this error :
Requested action not taken: mailbox unavailable
💡 SES permits only one active ruleset at a time, so when you create a new ruleset, it will not be activated automatically.
To activate the ruleset, you can use AWS CDK custom resources with SDK calls, as shown below.
const rulesetActivationSDKCall: AwsSdkCall = {
service: 'SES',
action: 'setActiveReceiptRuleSet',
physicalResourceId: PhysicalResourceId.of('SesCustomResource'),
};
const setActiveReceiptRuleSetSdkCall: AwsSdkCall = {
...rulesetActivationSDKCall,
parameters: { RuleSetName: receiptRuleSet.receiptRuleSetName }
};
const deleteReceiptRuleSetSdkCall: AwsSdkCall = rulesetActivationSDKCall;
new AwsCustomResource(this, "setActiveReceiptRuleSetCustomResource", {
onCreate: setActiveReceiptRuleSetSdkCall,
onUpdate: setActiveReceiptRuleSetSdkCall,
onDelete: deleteReceiptRuleSetSdkCall,
logRetention: RetentionDays.ONE_WEEK,
policy: AwsCustomResourcePolicy.fromStatements([
new PolicyStatement({
sid: 'SesCustomResourceSetActiveReceiptRuleSet',
effect: Effect.ALLOW,
actions: [
'ses:SetActiveReceiptRuleSet',
'ses:DeleteReceiptRuleSet',
],
resources: ['*']
}),
]),
});
This solution lets to activate the created ruleset and the ses reception now works as expected.
💡 The provided example only trigger a lambda function that logs an ses event but you can implement your email forwarding if needed.
Organization and Accounts
The CDK for AWS Organizations only offers L1 constructs, but I find this approach simple and straightforward. In my opinion, adding L2 constructs might be unnecessary over-engineering, so Im fine with this CDK decision for now.
To create an Organization and an OU, the only parameters required are the FeatureSet of the Organization, which can be either CONSOLIDATED_BILLING or ALL.
const orga =new CfnOrganization(this, 'Organization', { featureSet: 'ALL' });
const orgUnit = new CfnOrganizationalUnit(this, 'OrganitationUnit', {
name: `workloads${tempSuffix}`,
parentId: orga.attrRootId
});
orgUnit.addDependency(orga);
The following snippets illustrate how to create accounts. In this example repository, the account list is provided through configuration, meaning the accounts parameter will be an array of objects in the format { accountName: string }. The created account IDs will be stored in the parameter store, although this may not be necessary for a personal organization setup.
ACCOUNTS.forEach((account: { accountName: string }) => {
const awsAccount =new CfnAccount(this, `${account.accountName}Account`, {
accountName: `${account.accountName}${tempSuffix}`,
email: `${account.accountName}${tempSuffix}@${DOMAIN_NAME}`,
parentIds: [orgUnit.attrId],
});
const param = new StringParameter(this, `${account.accountName}AccountIdParam`, {
stringValue: awsAccount.attrAccountId,
description: `Account ID for ${awsAccount.accountName}`,
parameterName: `/${this.ENV}/${this.CONTEXT}/${awsAccount.accountName}/account/id`,
})
});
To set up SSO using IAM Identity Center, it's essential to enable AWS Organization trusted access. Unfortunately, theres no way to activate this feature using CDK or CloudFormation, so we will once again rely on a Custom Resource. In this example, all services required for trusted access are specified through configuration (e.g., sso.amazonaws.com and servicequota.amazonaws.com).
trustedServices.forEach((service: string) => {
const identifier = service.replace('.', '');
const enable: AwsSdkCall = {
service: 'organizations',
action: 'enableAWSServiceAccess',
physicalResourceId: PhysicalResourceId.of(`OrgCustomResource${identifier}`),
parameters: { ServicePrincipal: service },
};
const disable: AwsSdkCall = {
service: 'organizations',
action: 'disableAWSServiceAccess',
physicalResourceId: PhysicalResourceId.of(`OrgCustomResource${identifier}`),
parameters: { ServicePrincipal: service },
};
new AwsCustomResource(this, `AWSServiceAccessActivation${identifier}CustomResource`, {
onCreate: enable,
onUpdate: enable,
onDelete: disable,
logRetention: RetentionDays.ONE_WEEK,
policy: AwsCustomResourcePolicy.fromStatements([
new PolicyStatement({
sid: 'OrgCustomResourceSetOrgAWSServiceActivation',
effect: Effect.ALLOW,
actions: [
'organizations:enableAWSServiceAccess',
'organizations:disableAWSServiceAccess',
],
resources: ['*']
}),
]),
});
})
SSO Setup
The provided example configuration uses a NotReady state by setting isReadyToDeploy = false, which prevents the CDK deploy from generating the SSO configuration, as the SSO instance has not yet been created. As mentioned earlier, this cannot be accomplished through automation or API calls; the only available API call for CreateInstance works solely for standalone accounts, not for Organization Management accounts.
Before changing the flag to true, you must go to the AWS Console in the management account, navigate to IAM Identity Center, and click the Enable button. After activation, youll need the SsoInstanceArn and IdentityStoreId, which should be set in the stack configuration file along with isReadyToDeploy=true.
Once these steps are completed, the CDK deploy will proceed to deploy the stack along with all associated groups and permission sets.
const group = new CfnGroup(this, id, {
displayName: `${id}`,
description: `${id} Group`,
identityStoreId,
});
const permissionSet = new CfnPermissionSet(this, `${id}PermissionSet`, {
name: `${id}@${ENV}`,
description: `${id}@${ENV}`,
instanceArn: ssoInstanceArn,
managedPolicies: managedPolicies,
inlinePolicy: undefined,
sessionDuration: Duration.hours(12).toIsoString(),
});
accounts.forEach((account) => {
new CfnAssignment(this, `${id}Assignment`, {
instanceArn: ssoInstanceArn,
permissionSetArn: permissionSet.attrPermissionSetArn,
principalId: group.attrGroupId,
principalType: 'GROUP',
targetId: account,
targetType: 'AWS_ACCOUNT',
});
})
In the CDK snippet above, a group is created in the Identity Store, and a permission set is established for the SSO Instance. This group is assigned to each of the accounts created earlier. The code illustrates the Group Construct, which is utilized as shown below.
// Org Accounts
const developmentAccount = StringParameter.fromStringParameterName(this, 'AccountSecurity', `/${this.ENV}/${this.CONTEXT}/security_b/account/id`).stringValue;
//Managed Policies
const adminManagedPolicy = ManagedPolicy.fromAwsManagedPolicyName('AdministratorAccess');
const poweredUserManagedPolicy = ManagedPolicy.fromAwsManagedPolicyName('PowerUserAccess');
const readonlyManagedPolicy = ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess');
new Group(this, 'Admin', {
contextVariables: this.CONTEXT_VARIABLES,
ssoInstanceArn: SSO_INSTANCE_ARN,
identityStoreId: IDENTITY_STORE_ID,
managedPolicies: [ adminManagedPolicy.managedPolicyArn ],
accounts: [ developmentAccount ]
});
new Group(this, 'PowerUser', {
contextVariables: this.CONTEXT_VARIABLES,
ssoInstanceArn: SSO_INSTANCE_ARN,
identityStoreId: IDENTITY_STORE_ID,
managedPolicies: [ poweredUserManagedPolicy.managedPolicyArn ],
accounts: [ developmentAccount ]
});
new Group(this, 'Developer', {
contextVariables: this.CONTEXT_VARIABLES,
ssoInstanceArn: SSO_INSTANCE_ARN,
identityStoreId: IDENTITY_STORE_ID,
managedPolicies: [ readonlyManagedPolicy.managedPolicyArn ],
accounts: [ developmentAccount ]
});
You can now create a user in IAM Identity Center and assign it to one or more groups. Next, navigate to the AWS Access Portal (accessible via the link from the Identity Center dashboard: https://d-123456788.awsapps.com/start), which will prompt you for login credentials.
Account Boostrap
It was an effective way to automate account creation and implement varying levels of security. However, an empty account requires several repetitive tasks before it can be considered usable. The example includes setting up GitHub OIDC and CDK Bootstrap to prepare the member accounts.
The bootstrap can be deactivated through configuration, and this will be verified in the organization stack as shown below.
if( BOOTSTRAP ) {
new Bootstrap(this, 'Bootstrap', {
contextVariables: props.contextVariables,
regions: [ this.REGION ],
organizationUnits: [ orgUnit ],
types: {
[BootstrapTypes.CDK]: { FileAssetsBucketKmsKeyId: 'AWS_MANAGED_KEY' },
[BootstrapTypes.GitHub]: { Owner: gitHubConfig.owner, Repo: '*' }
},
})
}
The bootstrap construct creates a set of CloudFormation StackSets to allow the management account to bootstrap the member accounts, which will be triggered when accounts are created or updated. Unfortunately, the CDK does not offer a straightforward way to use CDK stacks for StackSets. After researching online, I found many solutions to be overly complicated, so I opted to stick with the readily available YAML templates found across the web (even though I probably wont look at or modify them). I'm fine with this approach.
export enum BootstrapTypes {
GitHub = 'oidc-github.yml',
CDK = 'cdk-bootstrap-template.yml',
}
const { contextVariables: { stage: ENV, context: CONTEXT }, types: TYPES } = props;
const tags = Stack.of(this).tags.renderTags();
Object.keys(TYPES).forEach((value: string, index: number) => {
const typeIdentifier = value.replace('.yml', '').replace(/[^a-zA-Z]/g, '');
const cfnParams = Object.entries(TYPES[value as unknown as BootstrapTypes])
.map(([key, value]) => (
{ parameterKey: key, parameterValue: value } as CfnStackSet.ParameterProperty
));
new CfnStackSet(this, `BootstrapStackSet${typeIdentifier}`, {
permissionModel: "SERVICE_MANAGED",
stackSetName: `${CONTEXT}-bootstrap-${typeIdentifier}-${ENV}`,
description: `Account bootstrap StackSet ${typeIdentifier}`,
autoDeployment: { enabled: true, retainStacksOnAccountRemoval: false },
capabilities: ["CAPABILITY_NAMED_IAM"],
templateBody: readFileSync(join(process.cwd(), `/cdk/lib/orga/bootstrap/${value}`), 'utf8'),
parameters: cfnParams,
tags,
operationPreferences: { failureToleranceCount: 1, maxConcurrentCount: 1 },
stackInstancesGroup: [{
regions: props.regions,
deploymentTargets: {
organizationalUnitIds: props.organizationUnits.map((ou: { attrId: string }) => ou.attrId),
},
}],
});
});
Conslusion
For a long time, I had been trying to set up an organization, but since I couldn't find a working piece of code, I decided to dive into it myself. It was an exciting experience, tackling different challenges and solving them along the way. Kudos to AWS CDK for its flexibility!
Having an organization is a great way to experiment and easily tear things down afterward. When closing accounts, youll incur charges for 90 days after theyve been removed from the organization. However, after this 90-day period, the accounts will be permanently deleted. During this time, you can still recover the account, access it with limited permissions, and perform certain actions.
SOCIAL SHARE CARD GENERATOR