Table of Contents

This article demonstrates how to use Terraform features new in February 2023 to comprehensively manage permission set assignments in AWS Single-Sign-On / IAM identity Centre across accounts and by Organizational Unit.

Note on Naming

‘AWS Single-Sign-On’ is a descriptive but fairly cumbersome name. In 2022 AWS decided to re-brand it to ‘IAM identity Center’, which IMO is confusing. For the sake of brevity in this article I am referring to both interchangeably as ‘AWS-SSO’

Background

I first started working with AWS-SSO at the beginning of 2021 as part of building out a Control Tower Organization. At the time there was no API support and everything had to be managed via the console. There were a few changes made that Spring that meant that SSO Permission sets, SSO Groups, and SSO Assignments could be managed by API and it wasn’t too long before Terraform implemented support for this- Great! It was annoying that it wasn’t possible to create and manage SSO users and groups or to query the Organization properly but it was bearable. In December 2021 I moved to other projects. In early 2023 I returned to Control Tower for a new project and was pleased to see that API support for SSO Groups and Users was now implemented. I was disappointed that Terraform was not able to query comprehensively for its own map of accounts to assign in order to make assignments to all accounts in an Organizational Unit (‘OU’). Specifically, as at Jan 20 2023 Terraform (still) did not support ‘aws organizations list-accounts-for-parent’ - so to ‘assign a group and permission set to an OU’ we had to use reflection and supply a map of accounts by OU separately. Happily, on 17 February 2023 release v4.55.0 of the Terraform AWS provider implemented this:

  • New Data Source: aws_organizations_organizational_unit_child_accounts (#24350)
  • New Data Source: aws_organizations_organizational_unit_descendant_accounts (#24350)

This means that it should now be possible to use Terraform to query an AWS Organization to get all of the OUs and accounts within them. Finally! How to make use of this?

Notes and opinionation

It’s not possible to make an assignment directly to an OU, only to an account. It is possible, after a fashion, to do this using CloudFormation StackSets but that is considerably more complex and would be a whole other article!

It is considered and explicitly advised as poor practice to create assignments using individual SSO users. All assignments should be for a group. This is the case with discussion here.

Mapping accounts and Organizational Units in our Organization

I consider it a great advantage to be able to use maps over lists - there are no concerns about reflowing when entries are added or removed and we can use meaningful names to reference attributes and outputs. This is especially important when working with access control and privilege separation where such reflow could inadvertently elevate privileges or lock people out.

Basic data object queries

We need some initial data queries to get information to work with:


data "aws_organizations_organization" "org" {}

data "aws_organizations_organizational_units" "ou" {
  parent_id = data.aws_organizations_organization.org.roots[0].id
}

Producing a full, detailed map of accounts in the organization:

This can be useful (see below) but it is very complex and detailed and sadly does not include OU’s - OU is not an ‘attribute’ of account

output "full_org_accounts_map" {
  description = "includes only accounts, not OUs"
  value       = data.aws_organizations_organization.org
}

Producing maps of OUs

This produces a map with a list of children OU maps and the parent OU ID. Not especially useful except as an intermediate step:

output "ous" {
  description = "map of ous in org"
  value       = data.aws_organizations_organizational_units.ou
}

If we wanted to access the child OUs for other actions, the following is probably more likely to more useful (see below):

output "ous_children_list" {
  description = "list of child ous"
  value       = data.aws_organizations_organizational_units.ou.children
}

Looping a query to get accounts by OU

Because OU is not an attribute of account, we have to loop our query to:

  • Query the Org for OUs
  • For each OU, query for descendants

Terraform doesn’t let us use for_each in locals or outputs but we can use it in data:

data "aws_organizations_organizational_unit_descendant_accounts" "org" { # nested map of accounts by child ou with all attributes
  for_each  = { for ou in data.aws_organizations_organizational_units.ou.children : ou.name => ou.id }
  parent_id = each.value
}

This returns a comprehensive nested map with each account’s attributes as a map, listed by OU. Almost there!

Rendering ‘simple’ maps to use for assignments

output "accounts_nested_by_ou" {
  description = "nested map of all accounts (except master) by child OU if no nested OUs in org"
  value = { for ou_name, ou_attributes in data.aws_organizations_organizational_unit_descendant_accounts.org :
    ou_name => { for accounts in ou_attributes.accounts :
      accounts.name => accounts.id
    }
  }
}

This produces a nested map of all accounts in child OUs. It does not include the master/management account. Output looks like:

  + accounts_nested_by_ou   = {
      + AFT      = {
          + AFT-Management = "444444444444"
        }
      + Sandbox  = {
          + sandbox-00 = "555555555555"
          + sandbox-01 = "666666666666"
        }
      + Security = {
          + audit       = "222222222222"
          + log-archive = "333333333333"
        }
    }

We can also separately render a flattened map of all accounts:

output "single_accounts" {
  value = { for account in data.aws_organizations_organization.org.accounts :
  account.name => account.id }
  description = "produces a flat map of ALL accounts in org {name=id}"
}

Output looks like:

  + single_accounts          = {
      + AFT-Management = "444444444444"
      + audit          = "222222222222"
      + log-archive    = "333333333333"
      + management     = "111111111111"
      + sandbox-00     = "555555555555"
      + sandbox-01     = "666666666666"
    }

Putting what we have described into practice as a Terraform plan for SSO assignments


#################  
# look up details for our org accounts and OUs
#################  

data "aws_organizations_organization" "org" {}

data "aws_organizations_organizational_units" "ou" {
  parent_id = data.aws_organizations_organization.org.roots[0].id
}

#################
# Get a nested map of accounts by child ou with all attributes
#################

data "aws_organizations_organizational_unit_descendant_accounts" "org" {
  for_each  = { for ou in data.aws_organizations_organizational_units.ou.children : ou.name => ou.id }
  parent_id = each.value
}

#################  
# look up details for aws managed permission set "AWSAdministratorAccess"
#################  

data "aws_ssoadmin_instances" "this" {}

data "aws_ssoadmin_permission_set" "aws_administrator_access" {
  instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
  name         = "AWSAdministratorAccess"
}

#################  
# Create an SSO group to use "AWSAdministratorAccess" on all accounts in Sandbox OU
#################  

resource "aws_identitystore_group" "AWSAdministratorSandboxAccounts" {
  display_name      = "AWSAdministratorSandboxAccounts"
  description       = "AWSAdministratorSandboxAccounts"
  identity_store_id = tolist(data.aws_ssoadmin_instances.this.identity_store_ids)[0]
}

#################  
# create simpler nested map of all accounts (name=>id) (except master) by child OU if no nested OUs in org
#################  

locals {
  accounts_nested_by_ou = { for ou_name, ou_attributes in data.aws_organizations_organizational_unit_descendant_accounts.org :
    ou_name => { for accounts in ou_attributes.accounts :
      accounts.name => accounts.id
    }
  }
}

#################  
# assign group `AWSAdministratorAccess` permission set on all accounts in `Sandbox` OU
#################  

resource "aws_ssoadmin_account_assignment" "AWSAdministratorSandboxAccounts_on_SandboxAccounts" {
  for_each           = local.accounts_nested_by_ou["Sandbox"]
  instance_arn       = tolist(data.aws_ssoadmin_instances.this.arns)[0]
  permission_set_arn = data.aws_ssoadmin_permission_set.aws_administrator_access.arn
  principal_id       = aws_identitystore_group.AWSAdministratorSandboxAccounts.group_id
  principal_type     = "GROUP"
  target_id          = each.value
  target_type        = "AWS_ACCOUNT"
}

#################  
# look up details for aws managed permission set "AWSReadOnlyAccess"
#################  

data "aws_ssoadmin_permission_set" "AWSReadOnlyAccess" {
  instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]
  name         = "AWSReadOnlyAccess"
}

#################  
# Create an SSO group to use "AWSReadOnlyAccess" on all accounts in our org
#################  

resource "aws_identitystore_group" "AWSReadOnlyAccess" {
  display_name      = "AWSReadOnlyAccess"
  description       = "AWSReadOnlyAccess"
  identity_store_id = tolist(data.aws_ssoadmin_instances.this.identity_store_ids)[0]
}

#################  
# create flat map of all accounts (name=>id) (including master) 
#################  

locals {
  flat_map_of_all_accounts = { for account in data.aws_organizations_organization.org.accounts :
  account.name => account.id }
}

#################  
# assign group `AWSAdministratorAccess` permission set on all accounts
#################  

resource "aws_ssoadmin_account_assignment" "AWSReadOnlyAccess_on_all_accounts" {
  for_each           = local.flat_map_of_all_accounts
  instance_arn       = tolist(data.aws_ssoadmin_instances.this.arns)[0]
  permission_set_arn = data.aws_ssoadmin_permission_set.AWSReadOnlyAccess.arn
  principal_id       = aws_identitystore_group.AWSReadOnlyAccess.group_id
  principal_type     = "GROUP"
  target_id          = each.value
  target_type        = "AWS_ACCOUNT"
}

Summary

The great advantage of using maps in this way is that we can use the name of an account or an ou to reference its ID. This is great for legibility, meaningful references and meaningful output, e.g. the above will create assignments similar to:

# aws_ssoadmin_account_assignment.AWSAdministratorSandboxAccounts_on_SandboxAccounts["sandbox-00"] will be created

# aws_ssoadmin_account_assignment.AWSAdministratorSandboxAccounts_on_SandboxAccounts["sandbox-01"] will be created

# aws_ssoadmin_account_assignment.AWSReadOnlyAccess_on_all_accounts["audit"] will be created

# aws_ssoadmin_account_assignment.AWSReadOnlyAccess_on_all_accounts["log-archive"] will be created

I would even recommend maps and for_each for a single-account assignment for this reason, e.g. this local:

#################  
# create map per account of all accounts (name=>id) (including master) to use as for_each for single account assignments
################# 
locals {
  single_account_maps = { for account in data.aws_organizations_organization.org.accounts :
  account.name => { (account.name) = (account.id) } }
}

permits us to loop assignments like:

  for_each           = local.single_account_maps.sandbox-00

There is no silliness or confusion around account names for instance - these are canonical and consistent rather than some local name. By using maps in all cases we can also use Terraform’s merge function to loop assignments for an arbitrary assortment of OU’s and single account(s).

Caution

The above is demonstrated on an Organization without nested OUs- bear this in mind if you are using these!