Serverless Event Driven AWS IAM Identity Center Permission Set Automation

The post introduces a solution for automating AWS Identity Center permissions management. The solution manages AWS accounts and permission set assignments for groups created within AWS Identity Center from an external Identity Provider (such as AzureAD, Okta, etc.).

This post assumes a familiarity with the AWS IAM service and concepts such as roles and policies.

Introduction to Central Identity and Access Control on AWS

AWS multi-account environments are commonly setup with central identity and access control instead of doing this on a per individual account basis. AWS offers the IAM Identity Center (formerly Single Sign On) service for centralised access control, permissions and access to AWS accounts.

Identity Center supports bringing an organisation's existing users and groups into the AWS environment. IAM's documentation refers to this as using an external identity provider (IdP) to federate users and groups with Identity Center.

Within Identity Center permissions are managed via permission sets, which in turn are a collection of IAM policies. Once a permission set has been assigned for a user or group to an account, Identity Center will automatically create IAM roles in the account. The role's policies are configured from the permission set. In addition the role has a trust policy configured that allows the role to be only assumed when the user has been authenticated from the federated identity provider.

Managing AWS access control and permissions should always follow the least privilege permissions concept for any task for the environment's users. AWS environments typically are setup for role based access control where roles have sufficient permissions setup to accomplish the required tasks. Roles in the AWS Identity Center are commonly mapped to federated groups from the IdP. Depending on the requirements for granularity this potentially results in a 1:1 relationship between an IdP's groups and the combination of AWS account and permission set.

Let's look at a practical example. The AWS account 'app1-prod' should be accessed by the operators of the account using three different permissions depending on the tasks at hand.

For day to day regular operations most tasks should be read only and fall within the category of visibility, monitoring and observation. One can use AWS' provided AWS managed policy ReadOnlyAccess and the permissions defined by the policies would get the job done.

For emergency changes to the environment and management of the IAM service within the account the policies from AWS's managed `AdministratorAccess' policy can be used.

For any other tasks requiring interactive changes to the environment permissions defined the policies from AWS's managed PowerUserAccess policy can be used.

In the example scenario the IdP would have to hold three groups, giving the group's members access to the AWS account app1-prod with the three permission sets ReadOnlyAccess, AdministratorAccess and PowerUserAccess.

Creating the groups in the IdP and assignment of the permission sets to the AWS accounts for the group must be accomplished first before users from the groups can log into the AWS account.

Many organizations have automated AWS account creation since new applications and workloads need to be accommodated frequently. It is not uncommon for a workload to be deployed across several AWS accounts, each requiring IAM Identity Center setup before being handed over to the users of the accounts.

AWS does not provide an out of the box solution for automating the creation groups and permission sets to AWS account assignments and thus frequently this step is performed manually introducing delays and additional work.

Solution

This post describes a solution using AWS serverless resources for AWS Identity Center federated group to account and permission set assignment automation. This solution assumes that the naming of the group follows a regular pattern that contains the target AWS account and permission set as part of the name of the group.

As an example we will use the group name aws_accountname_a. The solution implementation is built with the following assumptions that the group name encodes:

  • A constant prefix for groups for AWS account access (the string aws_)
  • The account name where the group should be assigned to (the string accountname - any non white space characters between the _ characters of the whole group name)
  • A short name of the permission set (the string _p to refer to for AWSPowerUserAccess permission set) that the group should be assigned with on the account

Prerequisites

  • The user has an AWS account that has IAM Identity Center enabled.
  • The user has IAM Identity Center configured to use an external identity provider for user and group federation.
  • The external identity provider is configured, and set up for automatic provisioning, see the references section of this post.
  • The user has adequate permissions to manage resources within the account (using CloudFormation or using other methods).

High Level Architecture

The following diagram shows a high level architecture of the solution.

Image description

Workflow of the Solution

The basic work flow of the solution is highlighted in the following steps:

  • The external identity provider provisions a group within the AWS IAM Identity Center using the SCIM protocol between the identity provider and AWS IAM Identity Center, triggering an AWS CloudTrail event.
  • A AWS EventBridge rule monitors for the event raised upon provisioning and triggers an AWS Lambda function, passing the event details to the function.
  • The function decodes the group name from the event detail as it contains the desired accout name, the desired permission set name to be assigned to the group and performs the assignment within IAM Identity Center API.
  • The process is logged in an AWS CloudWatch log group for auditing and debugging purposes.
  • In case of failure an email will be dispatched.

Implementation

The solution is implemented in AWS CloudFormation but should be fairly portable to be implemented in other Infrastructure as Code frameworks such as Terraform, etc.

The main components are the EventBridge rule to listen for the desired event, the Lambda function and it's IAM permissions and the SNS component for sending email on failure.

CloudFormation Template

Description: AWS SSO Automation Components

Parameters:
  ManagedResourcePrefix:
    Type: String
  InstanceArn:
    Type: String
  SMTPNotifyAddress:
    Type: String
  TopicName:
    Type: String
    Default: "AssigmentTopic"

Resources:
  NewSSOGroupEventRule:
    Type: AWS::Events::Rule
    Properties:
      Description: Trigger for when a new SSO Group is propagated from Azure AD via SCIM
      EventPattern:
        source:
        - aws.sso-directory
        detail-type:
        - AWS API Call via CloudTrail
        detail:
          eventSource:
          - sso-directory.amazonaws.com
          eventName:
          - CreateGroup
      Targets:
        - Arn: !GetAtt SsoAssignGroupsFunction.Arn
          Id: sso-assign-group-function
  ExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
        - Effect: Allow
          Principal:
            Service: lambda.amazonaws.com
          Action:
          - sts:AssumeRole
      ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
      - PolicyName: SSOandOrgPermissions
        PolicyDocument:
          Version: 2012-10-17
          Statement:
          - Effect: Allow
            Action:
            - sso-directory:Describe*
            - sso-directory:Get*
            - sso-directory:List*
            - sso-directory:Describe*
            - sso-directory:Search*
            - sso:Describe*
            - sso:Get*
            - sso:List*
            - sso:CreateAccountAssignment
            - sso:ProvisionPermissionSet
            - identitystore:List*
            - identitystore:Describe*
            - organizations:ListAccounts
            - sns:Publish
            Resource: '*'
  AssignmentTopic:
    Type: AWS::SNS::Topic
    Properties:
      Subscription:
        - Endpoint: !Ref SMTPNotifyAddress
          Protocol: "email"
      TopicName: !Ref TopicName
  SsoAssignGroupsFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          """ This function is intended to be a standalone Lambda function for SSO Permissionset
          to AWS Account mapping """

          import re
          import os
          import logging
          import json
          from time import sleep
          import boto3
          import traceback

          logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG)
          logger = logging.getLogger()
          logger.setLevel(logging.DEBUG)


          # This function assumes that groups are named like aws_<account-name>_<a|r|p>
          # see the documentation of this function for details.
          CONST_PREFIX = "aws"
          g_account_pattern = rf"^{CONST_PREFIX}_(\S*)_(\S*)"

          PSET_NAME_MAPPING_DICT = {
              "r": "AWSReadOnlyAccess",
              "a": "AWSAdministratorAccess",
              "p": "AWSPowerUserAccess",
          }

          sso_admin_client = boto3.client("sso-admin")
          org_client = boto3.client("organizations")
          sns_resource = boto3.resource("sns")


          def list_permission_sets(sso_instance_arn) -> dict:
              """Returns a dictionary of permissionssets for a given SSO Instance."""
              perm_set_dict = {}
              response = sso_admin_client.list_permission_sets(InstanceArn=sso_instance_arn)
              results = response["PermissionSets"]
              while "NextToken" in response:
                  response = sso_admin_client.list_permission_sets(
                      InstanceArn=sso_instance_arn, NextToken=response["NextToken"]
                  )
                  results.extend(response["PermissionSets"])

              for permission_set in results:
                  perm_description = sso_admin_client.describe_permission_set(
                      InstanceArn=sso_instance_arn, PermissionSetArn=permission_set
                  )
                  perm_set_dict[perm_description["PermissionSet"]["Name"]] = permission_set
              return perm_set_dict


          def list_aws_accounts() -> list:
              """Returns a list of account dictionaries containing  name id of each account"""
              account_list = []
              paginator = org_client.get_paginator("list_accounts")
              page_iterator = paginator.paginate()

              for page in page_iterator:
                  for acct in page["Accounts"]:
                      # only add active accounts
                      if acct["Status"] == "ACTIVE":
                          data = {"name": acct["Name"], "id": acct["Id"]}
                          # retrieve tags as well, can be used to target accounts given certain tags.
                          #tags = org_client.list_tags_for_resource(ResourceId=acct["Id"])["Tags"]
                          #logger.info("Tags found '%s', tags)
                          account_list.append(data)
              #logger.debug("List of accounts: %s", account_list)
              return account_list


          def lambda_handler(event, context):
              #"""Main method for Lambda function, will handle the IAM Identity Center permissionset to IAM Identity Center directiry group and AWS account mapping"""
              logger.debug("Invoked with event: %s", event)
              try:
                  group_display_name = event["detail"]["responseElements"]["group"]["displayName"]
                  if group_display_name == "":
                      logger.debug(
                          "Recieved SCIM CreateGroup event for roup name '%s'", group_display_name
                      )
                      raise Exception("Event did not contain the group display name property")

                  result = re.search(g_account_pattern, group_display_name)
                  print(result)
                  if not result:
                      logger.error(
                          "Security group: '%s' does not matching naming convention for account assignment. REGEX retourned matches: %s",
                          group_display_name, result,
                      )
                      raise Exception(
                          "Security group does not match convention for account assignment automation"
                      )

                  account_name = result.group(1)
                  short_name_pset = result.group(2)  # "a" "p" or "r"

                  if short_name_pset not in PSET_NAME_MAPPING_DICT:
                      logger.error(
                          "Short name '%s' for permission set is not known", short_name_pset
                      )
                      raise Exception("Short name for permission set is not known")

                  permission_set_name = PSET_NAME_MAPPING_DICT[short_name_pset]
                  logger.debug(
                      "Searching for account '%s' and permission set '%s'",
                      account_name,
                      permission_set_name,
                  )

                  accounts = list_aws_accounts()

                  instance_arn = os.getenv("INSTANCE_ARN")
                  logger.info("IAM Identity Center Instance ARN is configured '%s'", instance_arn)

                  if instance_arn is None:
                      raise Exception("No IAM Idenity Center Instance ARN is configured.")

                  logger.debug("Found IAM Idenity Center instance arn '%s'", instance_arn)
                  permission_sets = list_permission_sets(instance_arn)

                  logger.debug("Permission sets found '%s'", permission_sets)

                  account_id, permission_set_arn, account_name = None, None, None
                  for account in accounts:
                      logger.info("Id of desired account is '%s' ", account["id"])
                      account_id = account.get("id")
                      account_name = account.get("name")

                  if account_id is None:
                      logger.error("Can't find desired account '%s'", account_name)
                      raise Exception("Clould not find account")

                  for name, arn in permission_sets.items():
                      if name == permission_set_name:
                          logger.info("ARN of desired permission set is '%s'", arn)
                          permission_set_arn = arn

                  if permission_set_arn is None:
                      logger.error("Can't find desired permission set %s", permission_set_arn)
                      raise Exception("Can't find desired permission set")

                  principal_id = event["detail"]["responseElements"]["group"]["groupId"]
                  logger.info("PrincipalId of the group is: %s", principal_id)
                  if principal_id is None:
                      logger.error("Could not retrieve the princiapal id of group %s", principal_id)
                      raise Exception("Could not retrieve the princial id of the group")

                  request = {
                      "InstanceArn": instance_arn,
                      "TargetId": account_id,
                      "TargetType": "AWS_ACCOUNT",
                      "PermissionSetArn": permission_set_arn,
                      "PrincipalType": "GROUP",
                      "PrincipalId": principal_id,  #AWS IAM Identity Center group identifier
                  }

                  cracct_response = sso_admin_client.create_account_assignment(**request)
                  cracct_request_id = cracct_response["AccountAssignmentCreationStatus"][
                      "RequestId"
                  ]

                  for tries in range(5):
                      # The docs explain the following valid status states "IN_PROGRESS"|"FAILED"|"SUCCEEDED"
                      ps_prov_set_status = (
                          sso_admin_client.describe_account_assignment_creation_status(
                              InstanceArn=instance_arn,
                              AccountAssignmentCreationRequestId=cracct_request_id,
                          )
                      )
                      logger.info("Assignment attempt %s", tries)
                      status = ps_prov_set_status["AccountAssignmentCreationStatus"]["Status"]

                      if status == "IN_PROGRESS":
                          logger.info("Assignment is in progress")
                          logger.info("Sleeping for 5 seconds")
                          sleep(5.0)
                          continue
                      if status == "FAILED":
                          logger.error("Account assigned has failed")
                          raise Exception("Account assignment has failed")
                          return

                  logger.info(
                      "SUCCESS: Security Group: %s assigned to Account: %s with permission set: %s",
                      group_display_name,
                      account_name,
                      permission_set_name,
                  )
              except Exception as err:
                  message = {"Exception Details": str(err),
                                    "event": event}
                  return message
                  sns_topic = os.getenv("SNS_TOPIC")
                  topic = sns_resource.Topic(sns_topic)
                  topic.publish(
                      Message=json.dumps(message),
                      Subject="Account Association Operation Failed: SSO Automation",
                  )
                  logger.info("Error notification sent via SNS")

      Handler: 'assign_group_to_account.lambda_handler'
      Role: !GetAtt ExecutionRole.Arn
      Runtime: 'python3.9'
      MemorySize: 128
      Timeout: 900
      Environment:
        Variables:
          SNS_TOPIC: !Ref AssignmentTopic
          INSTANCE_ARN: !Ref InstanceArn
  EventsFunctionPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt SsoAssignGroupsFunction.Arn
      Principal: events.amazonaws.com
      SourceArn: !GetAtt NewSSOGroupEventRule.Arn

References

See docs.aws.amazon.com/singlesignon/latest/use.. for details on how to set a external Identity Provider configuration for provisioning.

Did you find this article valuable?

Support Grumpy Platform Engineer by becoming a sponsor. Any amount is appreciated!