Automate AWS IAM Access with Python

Write Python scripts using Boto3 to automate IAM users, groups, and policies.

Introduction

30 Second Summary

When a sports team wins a championship, dozens of people suddenly need access to performance data. Analysts need to read stats, engineers need to train models, and product managers need dashboards. Giving each person the right level of access by clicking through a console is slow and error-prone.

In this project, you will write Python scripts that automate AWS access control for the New York Knicks' championship analytics platform using Boto3, AWS's Python SDK.

What You'll Build

You'll build a set of Python scripts that programmatically create IAM groups, users, and security policies for a cross-functional data team.

By the end of this project, you'll have:

  • A setup script that creates IAM groups, users, and policy attachments using Boto3.
  • A custom IAM policy with policy variables for per-user S3 folder scoping and condition keys requiring HTTPS.
  • A test script that validates least-privilege access by catching AccessDenied errors programmatically.
  • Secret Mission: Use the IAM Policy Simulator API to validate policies without making real AWS calls.

Are there any prerequisites?

You'll need an existing AWS account with admin access and Python 3.10+ installed on macOS. Step 1 walks you through installing the AWS CLI and Boto3.

Install Tools and Configure AWS Credentials

To automate IAM resource creation with Python, you need two tools talking to each other: the AWS CLI (which stores your credentials) and Boto3 (which uses those credentials to make API calls from your scripts).

In this step, you will install the AWS CLI v2, configure it with your AWS credentials, and install Boto3 inside a new project folder.

In this step, get ready to:
  • Install and verify the AWS CLI v2 on macOS.
  • Configure your AWS credentials for programmatic access.
  • Create a project folder and install Boto3.
Install the AWS CLI v2 on macOS

The AWS CLI lets you interact with AWS services from your terminal. Boto3 reads the credentials that the CLI stores, so the CLI installation is a prerequisite for everything else in this project.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Download and install the AWS CLI v2 package by running these commands:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /

What do these commands do?

  • curl downloads the official AWS CLI v2 installer package from Amazon's servers.
  • sudo installer runs the macOS package installer with admin permissions, placing the CLI binary at /usr/local/bin/aws.

You will be prompted for your password

The sudo command requires your macOS login password. When you type it, no characters appear on screen. Just type the password and press Enter.

  • Verify the installation by running:
aws --version

Check your output and pick the tab that matches what you see.

✔️ I see aws-cli/2.x

Your output starts with aws-cli/2. followed by version details. The AWS CLI v2 is installed and ready.

ⓧ I see aws-cli/1.x

You have AWS CLI version 1 installed. You need to remove it before version 2 can take over.

  • Uninstall the old version by running:
pip3 uninstall awscli
  • Close your terminal completely, then reopen it.
  • Re-run the two install commands from above (the curl and sudo installer lines).
  • Run aws --version again. You should now see aws-cli/2. in the output.

ⓧ Command not found

The installation did not complete successfully.

  • Close and reopen your terminal to refresh the shell path.
  • Run aws --version again.
  • If it still fails, re-run the curl and sudo installer commands and watch for any error messages in the output.

If you are still stuck, visit the official AWS CLI install guide for macOS troubleshooting.

Configure the AWS CLI with your credentials

The CLI needs your AWS Access Key ID and Secret Access Key to authenticate API requests on your behalf. These credentials come from your AWS account's IAM console.

  • Start the interactive configuration wizard by running:
aws configure

What will this command ask?

The wizard prompts you for four values, one at a time:

  • AWS Access Key ID - paste your access key from your AWS account.
  • AWS Secret Access Key - paste your secret key from your AWS account.
  • Default region name - type us-east-1 (or your preferred region).
  • Default output format - type json.

This creates a ~/.aws/credentials file and a ~/.aws/config file on your machine. Boto3 automatically reads these files when making AWS API calls.

  • Enter each value when prompted, pressing Enter after each one.
  • Verify that your credentials work by running:
aws sts get-caller-identity

You should see JSON output containing your UserId, Account, and Arn fields. This confirms your credentials are configured correctly.

Seeing an error about credentials?

  • Double-check that you pasted your Access Key ID and Secret Access Key correctly. Even one extra space will cause authentication to fail.
  • Make sure your IAM user has programmatic access enabled in the AWS Console.
  • help me troubleshoot my AWS CLI credentials configuration
Install Boto3 and create your project folder

Boto3 is the AWS SDK for Python. It translates your Python function calls into AWS API requests, using the credentials you just configured.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir knicks-iam-project
cd knicks-iam-project
  • Confirm the folder exists by running:
pwd

You should see /Users/your-username/Desktop/knicks-iam-project in the output.

  • Install Boto3 by running:
pip3 install boto3

What is Boto3?

Boto3 is the official AWS SDK for Python. It provides Python methods that map to AWS API operations. For example, calling iam.create_user() in Python sends a CreateUser request to the IAM service.

It automatically reads the credentials from ~/.aws/credentials so you never hardcode secrets in your scripts.

  • Verify Boto3 is installed by running:
python3 -c "import boto3; print(boto3.__version__)"

You should see a version number printed (e.g. 1.43.37). This confirms Boto3 is importable and ready to use.

Import error or command not found?

  • If you see ModuleNotFoundError, the install might have gone to a different Python version. Try running python3 -m pip install boto3 instead.
  • Make sure you are using Python 3.10 or later. Check with python3 --version.
  • help me fix my Boto3 installation on macOS

Your environment is locked and loaded. Next up, you will write the Python script that creates the full IAM structure for the Knicks analytics team.

Write the IAM Setup Script

Your AWS credentials are configured and Boto3 is ready to go. Now it's time to write the Python script that builds the entire IAM structure for the Knicks analytics team.

Instead of clicking through the AWS Console one resource at a time, you'll use Boto3 to programmatically create groups, attach policies, and provision users. This is the same infrastructure-as-code approach that production teams use to manage access at scale.

In this step, get ready to:
  • Write a Python script that creates three IAM User Groups with role-appropriate managed policies.
  • Extend the script to create three IAM Users and assign them to groups with login profiles.
  • Run the script and verify the resources exist in the AWS Console.
Create the setup script with group configuration

The Knicks analytics team has three roles: Product Managers, AI Engineers, and Data Analysts. Each role needs different AWS permissions, so you'll map each to an IAM User Group with specific managed policies attached.

  • Create a new file called setup_iam.py in your knicks-iam-project folder and add the following code:
import boto3
import json
import secrets
import string

iam = boto3.client("iam")

# --- Configuration ---
GROUPS = {
    "ProductManagers": [
        "arn:aws:iam::aws:policy/ReadOnlyAccess"
    ],
    "AIEngineers": [
        "arn:aws:iam::aws:policy/AmazonSageMakerFullAccess",
        "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
    ],
    "DataAnalysts": [
        "arn:aws:iam::aws:policy/AmazonAthenaFullAccess",
        "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
    ],
}

USERS = {
    "knicks-pm-jordan": "ProductManagers",
    "knicks-ai-riley": "AIEngineers",
    "knicks-data-chen": "DataAnalysts",
}

CUSTOM_POLICY_NAME = "KnicksChampionshipDataPolicy"
CUSTOM_POLICY_FILE = "custom_policy.json"


def create_groups():
    """Create IAM groups and attach managed policies."""
    for group_name, policy_arns in GROUPS.items():
        print(f"Creating group: {group_name}")
        iam.create_group(GroupName=group_name)
        for policy_arn in policy_arns:
            iam.attach_group_policy(GroupName=group_name, PolicyArn=policy_arn)
            print(f"  Attached: {policy_arn}")

What does this code do?

This sets up the foundation for your IAM automation script.

  • boto3.client("iam") creates a connection to the AWS IAM service using the credentials you configured in Step 1.
  • The GROUPS dictionary maps each team role to its AWS Managed Policies. These are pre-built permission sets maintained by AWS.
  • The USERS dictionary maps each username to the group they belong to.
  • create_groups() loops through each group, creates it with create_group(), then attaches each policy using attach_group_policy().

Why attach policies to groups instead of users?

AWS recommends attaching policies to groups, not individual users. When a new team member joins, you add them to the right group and they inherit all the correct permissions automatically.

This is the least privilege principle in action. Each role gets only the permissions it needs, and managing access stays simple as the team grows.

You won't see a change yet. The next chunk adds user-creation logic and makes the script runnable.

Add user creation and the main function

Each user needs a secure password for console access. The script generates random passwords that meet AWS complexity requirements, creates each user, assigns them to their group, and sets up a login profile.

  • Add the following code below your create_groups() function in setup_iam.py:
def generate_password():
    """Generate a random password meeting AWS complexity requirements."""
    chars = string.ascii_letters + string.digits + "!@#$%^&*"
    password = (
        secrets.choice(string.ascii_uppercase)
        + secrets.choice(string.ascii_lowercase)
        + secrets.choice(string.digits)
        + secrets.choice("!@#$%^&*")
        + "".join(secrets.choice(chars) for _ in range(12))
    )
    return password


def create_users():
    """Create IAM users, assign to groups, and create login profiles."""
    credentials = []
    for username, group_name in USERS.items():
        print(f"Creating user: {username}")
        iam.create_user(UserName=username)
        iam.add_user_to_group(GroupName=group_name, UserName=username)
        password = generate_password()
        iam.create_login_profile(
            UserName=username,
            Password=password,
            PasswordResetRequired=True,
        )
        credentials.append({"username": username, "password": password})
        print(f"  Added to group: {group_name}")
    return credentials


def main():
    print("=" * 50)
    print("Knicks Analytics Platform - IAM Setup")
    print("=" * 50)

    print("\n--- Creating Groups ---")
    create_groups()

    print("\n--- Creating Users ---")
    credentials = create_users()

    print("\n--- Setup Complete ---")
    print("\nUser credentials (save these):")
    for cred in credentials:
        print(f"  {cred['username']}: {cred['password']}")


if __name__ == "__main__":
    main()

What does this code do?

  • generate_password() creates a 16-character random password. It guarantees at least one uppercase letter, one lowercase letter, one digit, and one special character to satisfy AWS password requirements.
  • create_users() loops through the USERS dictionary. For each user, it calls create_user(), adds them to their group with add_user_to_group(), and creates a console login profile with create_login_profile().
  • PasswordResetRequired=True forces each user to change their password on first login. This is a security best practice.
  • main() ties everything together. It calls create_groups() first (groups must exist before you can add users to them), then create_users().
  • Save setup_iam.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before running it in the next substep.

ⓧ I'd like to double check the full code

import boto3
import json
import secrets
import string

iam = boto3.client("iam")

# --- Configuration ---
GROUPS = {
    "ProductManagers": [
        "arn:aws:iam::aws:policy/ReadOnlyAccess"
    ],
    "AIEngineers": [
        "arn:aws:iam::aws:policy/AmazonSageMakerFullAccess",
        "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
    ],
    "DataAnalysts": [
        "arn:aws:iam::aws:policy/AmazonAthenaFullAccess",
        "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
    ],
}

USERS = {
    "knicks-pm-jordan": "ProductManagers",
    "knicks-ai-riley": "AIEngineers",
    "knicks-data-chen": "DataAnalysts",
}

CUSTOM_POLICY_NAME = "KnicksChampionshipDataPolicy"
CUSTOM_POLICY_FILE = "custom_policy.json"


def generate_password():
    """Generate a random password meeting AWS complexity requirements."""
    chars = string.ascii_letters + string.digits + "!@#$%^&*"
    password = (
        secrets.choice(string.ascii_uppercase)
        + secrets.choice(string.ascii_lowercase)
        + secrets.choice(string.digits)
        + secrets.choice("!@#$%^&*")
        + "".join(secrets.choice(chars) for _ in range(12))
    )
    return password


def create_groups():
    """Create IAM groups and attach managed policies."""
    for group_name, policy_arns in GROUPS.items():
        print(f"Creating group: {group_name}")
        iam.create_group(GroupName=group_name)
        for policy_arn in policy_arns:
            iam.attach_group_policy(GroupName=group_name, PolicyArn=policy_arn)
            print(f"  Attached: {policy_arn}")


def create_users():
    """Create IAM users, assign to groups, and create login profiles."""
    credentials = []
    for username, group_name in USERS.items():
        print(f"Creating user: {username}")
        iam.create_user(UserName=username)
        iam.add_user_to_group(GroupName=group_name, UserName=username)
        password = generate_password()
        iam.create_login_profile(
            UserName=username,
            Password=password,
            PasswordResetRequired=True,
        )
        credentials.append({"username": username, "password": password})
        print(f"  Added to group: {group_name}")
    return credentials


def main():
    print("=" * 50)
    print("Knicks Analytics Platform - IAM Setup")
    print("=" * 50)

    print("\n--- Creating Groups ---")
    create_groups()

    print("\n--- Creating Users ---")
    credentials = create_users()

    print("\n--- Setup Complete ---")
    print("\nUser credentials (save these):")
    for cred in credentials:
        print(f"  {cred['username']}: {cred['password']}")


if __name__ == "__main__":
    main()
Run the script and verify in the AWS Console

Your script is complete. Time to run it and confirm the IAM resources appear in your AWS account.

  • Run the setup script by entering this command in your terminal:
python3 setup_iam.py

You should see output listing each group being created, policies being attached, each user being added to their group, and the generated credentials at the end.

What should I see?

The output starts with a banner, then shows each group name followed by the policies attached to it. Next, each username prints with the group they were added to. Finally, you see a list of usernames and their generated passwords.

The passwords are randomly generated, so they will look different each time you run the script.

  • Open the AWS Management Console in your browser.
  • Search for IAM in the top search bar and select the IAM service.
  • Click User groups in the left navigation pane.
  • Confirm you see three groups: ProductManagers, AIEngineers, and DataAnalysts.
  • Click on DataAnalysts to verify it has AmazonAthenaFullAccess and AmazonS3ReadOnlyAccess in the Permissions tab.
  • Click Users in the left navigation pane.
  • Confirm you see knicks-pm-jordan, knicks-ai-riley, and knicks-data-chen listed.

Seeing an error when running the script?

  • If you see EntityAlreadyExists, it means a group or user with that name already exists in your account. You can delete them manually in the IAM Console, then re-run the script.
  • If you see InvalidClientTokenId or SignatureDoesNotMatch, your AWS credentials are incorrect. Run aws configure again and double-check your Access Key ID and Secret Access Key.
  • If you see AccessDenied, your AWS account credentials do not have permission to create IAM resources. Make sure you are using admin or root credentials.

help me debug my setup_iam.py script error

Your IAM groups and users are live in AWS. Next up, you'll create a custom policy that uses advanced features like policy variables and condition keys to scope access per user.

Create and Attach a Custom Policy

Your IAM groups and users are live, each with AWS managed policies that grant broad access categories. Managed policies are convenient, but they cover wide permission sets that may exceed what any individual user actually needs.

In this step, you will write a custom IAM policy that uses advanced features to enforce tighter, per-user access controls. The policy uses a policy variable to dynamically scope each user's S3 access to their own folder, and a condition key to require HTTPS on every request.

In this step, get ready to:
  • Create a custom JSON policy document with policy variables and condition keys.
  • Add a function to your setup script that registers the policy in IAM and attaches it to a group.
  • Run the new function and verify the policy in the AWS Console.
Create the custom policy JSON file

AWS managed policies apply the same permissions to everyone in a group. A custom policy lets you use dynamic values that change based on who is making the request.

What are policy variables and condition keys?

A policy variable like ${aws:username} gets replaced at evaluation time with the requesting user's IAM username. This means one policy can scope different users to different S3 folders without creating separate policies per user.

A condition key like aws:SecureTransport adds a requirement that must be true for the permission to apply. Setting it to true means the request must use HTTPS (encrypted in transit).

  • Create a new file called custom_policy.json in your knicks-iam-project folder by running:
touch custom_policy.json
  • Open custom_policy.json in your text editor and paste the following policy document:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowListBucketInUserFolder",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::knicks-championship-analytics",
      "Condition": {
        "StringLike": {
          "s3:prefix": ["${aws:username}/*"]
        },
        "Bool": {
          "aws:SecureTransport": "true"
        }
      }
    },
    {
      "Sid": "AllowGetObjectInUserFolder",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::knicks-championship-analytics/${aws:username}/*",
      "Condition": {
        "Bool": {
          "aws:SecureTransport": "true"
        }
      }
    }
  ]
}

What does this policy do?

  • Statement 1 (AllowListBucketInUserFolder): Allows s3:ListBucket on the knicks-championship-analytics bucket, but only for objects whose prefix matches the user's own folder.
  • Statement 2 (AllowGetObjectInUserFolder): Allows s3:GetObject only on objects inside the user's personal folder path.
  • Both statements require aws:SecureTransport to be true, meaning any request over plain HTTP is automatically denied.
  • The bucket does not need to exist for the policy to be valid. IAM evaluates the policy at request time regardless.
  • Save custom_policy.json.
  • Confirm the file exists by running:
ls

You should see custom_policy.json and setup_iam.py listed in the output.

Don't see custom_policy.json?

Make sure you ran touch custom_policy.json from inside the knicks-iam-project folder. Run pwd to check your current directory.

help me figure out why my custom_policy.json file wasn't created

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before continuing.

ⓧ I'd like to double check the full code

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowListBucketInUserFolder",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::knicks-championship-analytics",
      "Condition": {
        "StringLike": {
          "s3:prefix": ["${aws:username}/*"]
        },
        "Bool": {
          "aws:SecureTransport": "true"
        }
      }
    },
    {
      "Sid": "AllowGetObjectInUserFolder",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::knicks-championship-analytics/${aws:username}/*",
      "Condition": {
        "Bool": {
          "aws:SecureTransport": "true"
        }
      }
    }
  ]
}
Add the custom policy function to your setup script

Now you need a function that reads this JSON file, registers it as a customer managed policy in IAM, and attaches it to the DataAnalysts group.

  • Open setup_iam.py in your text editor.
  • Find the create_users() function. Add the following new function directly below it (before def main():):
def create_custom_policy():
    """Create and attach the custom S3 scoped policy."""
    with open(CUSTOM_POLICY_FILE, "r") as f:
        policy_document = f.read()

    response = iam.create_policy(
        PolicyName=CUSTOM_POLICY_NAME,
        PolicyDocument=policy_document,
        Description="Per-user S3 read access to Knicks championship analytics bucket, HTTPS only",
    )
    policy_arn = response["Policy"]["Arn"]
    print(f"Created custom policy: {policy_arn}")

    iam.attach_group_policy(GroupName="DataAnalysts", PolicyArn=policy_arn)
    print(f"  Attached to DataAnalysts group")
    return policy_arn

What does this function do?

  • It reads custom_policy.json as a raw string. The IAM API accepts the policy document as a JSON string, so no parsing is needed.
  • It calls iam.create_policy() to register the policy in your AWS account. The response includes the new policy's ARN.
  • It calls iam.attach_group_policy() to attach the new policy to the DataAnalysts group, giving those users per-folder S3 access.

Next, update the main() function to call create_custom_policy() and print the policy ARN.

  • In setup_iam.py, find the existing main() function and replace it entirely with:
def main():
    print("=" * 50)
    print("Knicks Analytics Platform - IAM Setup")
    print("=" * 50)

    print("\n--- Creating Groups ---")
    create_groups()

    print("\n--- Creating Users ---")
    credentials = create_users()

    print("\n--- Creating Custom Policy ---")
    policy_arn = create_custom_policy()

    print("\n--- Setup Complete ---")
    print("\nUser credentials (save these):")
    for cred in credentials:
        print(f"  {cred['username']}: {cred['password']}")
    print(f"\nCustom policy ARN: {policy_arn}")

What changed in main()?

  • Two new lines call create_custom_policy() and store the returned ARN in policy_arn.
  • A final print statement outputs the custom policy ARN so you can confirm it was created.
  • Save setup_iam.py.

Not sure where to place the new function?

The create_custom_policy() function must sit between create_users() and main(). Python reads top to bottom, so main() needs to see create_custom_policy defined above it.

help me figure out where to put create_custom_policy in my setup_iam.py file

✔️ Awesome, I've got everything!

Great. Double check you have saved setup_iam.py before continuing.

ⓧ I'd like to double check the full code

import boto3
import json
import secrets
import string

iam = boto3.client("iam")

# --- Configuration ---
GROUPS = {
    "ProductManagers": [
        "arn:aws:iam::aws:policy/ReadOnlyAccess"
    ],
    "AIEngineers": [
        "arn:aws:iam::aws:policy/AmazonSageMakerFullAccess",
        "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
    ],
    "DataAnalysts": [
        "arn:aws:iam::aws:policy/AmazonAthenaFullAccess",
        "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
    ],
}

USERS = {
    "knicks-pm-jordan": "ProductManagers",
    "knicks-ai-riley": "AIEngineers",
    "knicks-data-chen": "DataAnalysts",
}

CUSTOM_POLICY_NAME = "KnicksChampionshipDataPolicy"
CUSTOM_POLICY_FILE = "custom_policy.json"


def generate_password():
    """Generate a random password meeting AWS complexity requirements."""
    chars = string.ascii_letters + string.digits + "!@#$%^&*"
    password = (
        secrets.choice(string.ascii_uppercase)
        + secrets.choice(string.ascii_lowercase)
        + secrets.choice(string.digits)
        + secrets.choice("!@#$%^&*")
        + "".join(secrets.choice(chars) for _ in range(12))
    )
    return password


def create_groups():
    """Create IAM groups and attach managed policies."""
    for group_name, policy_arns in GROUPS.items():
        print(f"Creating group: {group_name}")
        iam.create_group(GroupName=group_name)
        for policy_arn in policy_arns:
            iam.attach_group_policy(GroupName=group_name, PolicyArn=policy_arn)
            print(f"  Attached: {policy_arn}")


def create_users():
    """Create IAM users, assign to groups, and create login profiles."""
    credentials = []
    for username, group_name in USERS.items():
        print(f"Creating user: {username}")
        iam.create_user(UserName=username)
        iam.add_user_to_group(GroupName=group_name, UserName=username)
        password = generate_password()
        iam.create_login_profile(
            UserName=username,
            Password=password,
            PasswordResetRequired=True,
        )
        credentials.append({"username": username, "password": password})
        print(f"  Added to group: {group_name}")
    return credentials


def create_custom_policy():
    """Create and attach the custom S3 scoped policy."""
    with open(CUSTOM_POLICY_FILE, "r") as f:
        policy_document = f.read()

    response = iam.create_policy(
        PolicyName=CUSTOM_POLICY_NAME,
        PolicyDocument=policy_document,
        Description="Per-user S3 read access to Knicks championship analytics bucket, HTTPS only",
    )
    policy_arn = response["Policy"]["Arn"]
    print(f"Created custom policy: {policy_arn}")

    iam.attach_group_policy(GroupName="DataAnalysts", PolicyArn=policy_arn)
    print(f"  Attached to DataAnalysts group")
    return policy_arn


def main():
    print("=" * 50)
    print("Knicks Analytics Platform - IAM Setup")
    print("=" * 50)

    print("\n--- Creating Groups ---")
    create_groups()

    print("\n--- Creating Users ---")
    credentials = create_users()

    print("\n--- Creating Custom Policy ---")
    policy_arn = create_custom_policy()

    print("\n--- Setup Complete ---")
    print("\nUser credentials (save these):")
    for cred in credentials:
        print(f"  {cred['username']}: {cred['password']}")
    print(f"\nCustom policy ARN: {policy_arn}")


if __name__ == "__main__":
    main()
Run the custom policy function and verify in the console

Your groups and users already exist from the previous step, so running the full script would hit errors trying to re-create them. Instead, run just the create_custom_policy() function on its own.

  • Run only the custom policy function by executing:
python3 -c "from setup_iam import create_custom_policy; create_custom_policy()"

You should see output showing the policy was created and attached:

Created custom policy: arn:aws:iam::123456789012:policy/KnicksChampionshipDataPolicy
  Attached to DataAnalysts group

Why not run the full script?

The create_groups() and create_users() functions would fail because those resources already exist from the previous step. The updated main() is designed for running the complete setup from scratch (for example, after a teardown).

  • Switch to your browser and open the AWS Management Console.
  • Search for and open IAM in the top search bar.
  • Click Policies in the left sidebar.
  • Type KnicksChampionshipDataPolicy in the search/filter field.
  • Click the policy name to open it, then click the JSON tab.

You should see the same two statements you wrote, with ${aws:username} in the resource path and aws:SecureTransport in the conditions.

  • Navigate back to IAM and click User groups in the left sidebar.
  • Click DataAnalysts to open the group.
  • Click the Permissions tab.

You should see three policies attached: AmazonAthenaFullAccess, AmazonS3ReadOnlyAccess, and KnicksChampionshipDataPolicy.

Seeing an EntityAlreadyExists error?

This means a policy named KnicksChampionshipDataPolicy already exists in your account. Go to IAM > Policies in the console, find and delete the existing policy, then run the command again.

help me fix the EntityAlreadyExists error when creating my custom IAM policy

Your custom policy is live and scoping access per user. Next up, you will test that these permissions actually work by simulating API calls as a Data Analyst and catching access denied errors programmatically.

Test Permissions Programmatically

Your IAM groups, users, and policies are all in place. But how do you know the permissions actually work?

In this step, you'll write a Python test script that impersonates one of your IAM users and attempts various AWS operations. The script catches AccessDenied errors programmatically to prove that least-privilege is enforced.

In this step, get ready to:
  • Create a test script that generates temporary credentials for an IAM user.
  • Validate that allowed operations succeed and denied operations raise errors.
  • Run the tests and confirm least-privilege enforcement.
Create the test script with access key helpers

To test permissions as a specific user, you need to make API calls using that user's credentials. The approach is to create a temporary access key, use it in a separate Boto3 session, then delete the key when you're done.

  • Create a new file called test_permissions.py in your knicks-iam-project folder.
  • Add the imports, globals, and access key helper functions by pasting this code:
import boto3
from botocore.exceptions import ClientError

iam = boto3.client("iam")
TEST_USER = "knicks-data-chen"


def create_test_access_key(username):
    """Create a temporary access key for testing."""
    response = iam.create_access_key(UserName=username)
    key = response["AccessKey"]
    return key["AccessKeyId"], key["SecretAccessKey"]


def delete_access_key(username, access_key_id):
    """Delete the temporary access key after testing."""
    iam.delete_access_key(UserName=username, AccessKeyId=access_key_id)

What does this code do?

The script imports ClientError from botocore so you can catch AWS permission errors by their error code.

The create_test_access_key() function generates a temporary access key for the specified user. The delete_access_key() function removes it after testing. This pair lets you impersonate a user without needing their console password.

Why test as knicks-data-chen?

The Data Analyst user has the most interesting permission boundary. They can read S3 data (via AmazonS3ReadOnlyAccess) but cannot write to S3 or touch IAM resources. Testing this user proves your least-privilege design works.

  • Save test_permissions.py.
Add the test logic and main function

Now you need the function that actually runs each permission test. It calls an AWS operation, checks whether the result matches what you expected, and returns a PASS/FAIL verdict.

  • Add the test_operation function below your helper functions in test_permissions.py:
def test_operation(session, service, operation, kwargs, expect_denied):
    """Test a single AWS operation and report results."""
    client = session.client(service)
    try:
        getattr(client, operation)(**kwargs)
        if expect_denied:
            return "FAIL (expected denied, got allowed)"
        return "PASS (allowed as expected)"
    except ClientError as e:
        if e.response["Error"]["Code"] in ("AccessDenied", "AccessDeniedException"):
            if expect_denied:
                return "PASS (denied as expected)"
            return "FAIL (expected allowed, got denied)"
        return f"ERROR ({e.response['Error']['Code']})"

What does this code do?

The test_operation() function uses Python's getattr() to dynamically call any Boto3 method by name. This lets you test different services and operations without writing separate functions for each.

If the call succeeds but you expected it to be denied, the test fails. If a ClientError is caught with the code AccessDenied or AccessDeniedException and that denial was expected, the test passes.

  • Save test_permissions.py.

Next, you need the main() function that orchestrates everything. It creates a temporary access key, runs four tests against different AWS services, then cleans up.

  • Add the main() function and entry point below test_operation in test_permissions.py:
def main():
    print("=" * 50)
    print("Knicks Analytics Platform - Permission Tests")
    print("=" * 50)

    print(f"\nCreating temporary access key for {TEST_USER}...")
    access_key_id, secret_key = create_test_access_key(TEST_USER)

    session = boto3.Session(
        aws_access_key_id=access_key_id,
        aws_secret_access_key=secret_key,
    )

    print("\n--- Running Tests ---\n")

    tests = [
        ("s3", "list_buckets", {}, False, "List S3 buckets (should be allowed)"),
        ("s3", "create_bucket", {"Bucket": "knicks-test-unauthorized-bucket"}, True, "Create S3 bucket (should be denied)"),
        ("iam", "list_users", {}, True, "List IAM users (should be denied)"),
        ("iam", "create_user", {"UserName": "hacker"}, True, "Create IAM user (should be denied)"),
    ]

    for service, operation, kwargs, expect_denied, description in tests:
        result = test_operation(session, service, operation, kwargs, expect_denied)
        print(f"  {description}: {result}")

    print(f"\nCleaning up access key...")
    delete_access_key(TEST_USER, access_key_id)
    print("Done. Access key deleted.")


if __name__ == "__main__":
    main()

What does this code do?

The main() function creates a temporary access key for knicks-data-chen and opens a fresh Boto3 session using those credentials. This session acts as that user, not as your admin account.

The tests list defines four scenarios: listing S3 buckets (allowed by AmazonS3ReadOnlyAccess), creating an S3 bucket (denied because read-only), listing IAM users (denied because no IAM policy), and creating an IAM user (denied). After all tests run, the temporary key is deleted.

  • Save test_permissions.py.

Seeing a syntax error on save?

Check that the tests list has matching parentheses and brackets. Each tuple must be on its own line, and the closing ] must align with the opening tests = [.

Also confirm that the if __name__ == "__main__": line is at the top level (no indentation). help me fix a syntax error in my test_permissions.py script

✔️ Awesome, I've got everything!

Great. Double check you've saved test_permissions.py before moving on.

ⓧ I'd like to double check the full code

import boto3
from botocore.exceptions import ClientError

iam = boto3.client("iam")
TEST_USER = "knicks-data-chen"


def create_test_access_key(username):
    """Create a temporary access key for testing."""
    response = iam.create_access_key(UserName=username)
    key = response["AccessKey"]
    return key["AccessKeyId"], key["SecretAccessKey"]


def delete_access_key(username, access_key_id):
    """Delete the temporary access key after testing."""
    iam.delete_access_key(UserName=username, AccessKeyId=access_key_id)


def test_operation(session, service, operation, kwargs, expect_denied):
    """Test a single AWS operation and report results."""
    client = session.client(service)
    try:
        getattr(client, operation)(**kwargs)
        if expect_denied:
            return "FAIL (expected denied, got allowed)"
        return "PASS (allowed as expected)"
    except ClientError as e:
        if e.response["Error"]["Code"] in ("AccessDenied", "AccessDeniedException"):
            if expect_denied:
                return "PASS (denied as expected)"
            return "FAIL (expected allowed, got denied)"
        return f"ERROR ({e.response['Error']['Code']})"


def main():
    print("=" * 50)
    print("Knicks Analytics Platform - Permission Tests")
    print("=" * 50)

    print(f"\nCreating temporary access key for {TEST_USER}...")
    access_key_id, secret_key = create_test_access_key(TEST_USER)

    session = boto3.Session(
        aws_access_key_id=access_key_id,
        aws_secret_access_key=secret_key,
    )

    print("\n--- Running Tests ---\n")

    tests = [
        ("s3", "list_buckets", {}, False, "List S3 buckets (should be allowed)"),
        ("s3", "create_bucket", {"Bucket": "knicks-test-unauthorized-bucket"}, True, "Create S3 bucket (should be denied)"),
        ("iam", "list_users", {}, True, "List IAM users (should be denied)"),
        ("iam", "create_user", {"UserName": "hacker"}, True, "Create IAM user (should be denied)"),
    ]

    for service, operation, kwargs, expect_denied, description in tests:
        result = test_operation(session, service, operation, kwargs, expect_denied)
        print(f"  {description}: {result}")

    print(f"\nCleaning up access key...")
    delete_access_key(TEST_USER, access_key_id)
    print("Done. Access key deleted.")


if __name__ == "__main__":
    main()
Run the test script

Time to see least-privilege in action. The script will impersonate the Data Analyst user and prove that their permissions are correctly scoped.

  • Run the test script by executing this command in your terminal:
python3 test_permissions.py

You should see output like this:

==================================================
Knicks Analytics Platform - Permission Tests
==================================================

Creating temporary access key for knicks-data-chen...

--- Running Tests ---

  List S3 buckets (should be allowed): PASS (allowed as expected)
  Create S3 bucket (should be denied): PASS (denied as expected)
  List IAM users (should be denied): PASS (denied as expected)
  Create IAM user (should be denied): PASS (denied as expected)

Cleaning up access key...
Done. Access key deleted.

What do these results prove?

The first test confirms that the AmazonS3ReadOnlyAccess policy grants read permissions as intended. The remaining three tests confirm that actions outside the user's granted permissions are blocked with AccessDenied errors.

This is the principle of least privilege in action. By default, a new IAM user has no permissions to do anything. Only the policies you explicitly attach determine what they can access.

Seeing all FAILs or unexpected errors?

If all tests show FAIL or ERROR, the IAM resources from Step 2 may not exist. Confirm the users and groups were created by running aws iam list-users in your terminal.

If you see NoSuchEntity errors, run python3 setup_iam.py again to recreate the IAM structure. help me debug my permission test results

Your permission tests confirm that the Knicks analytics team's access is properly scoped. Next up, you'll write a teardown script that cleanly removes all these IAM resources in the correct order.

Clean Up with a Teardown Script

Your setup script built the entire IAM structure. Your test script proved the permissions work. Now you need the third piece of the automation puzzle: a script that tears everything down cleanly.

AWS enforces strict dependency ordering for deletions. You cannot delete a group that still has attached policies or member users. Your teardown script must reverse the setup in exactly the right sequence, and it must handle the case where a resource was already deleted without crashing.

In this step, get ready to:
  • Write a teardown script that reverses the IAM setup in the correct dependency order.
  • Add idempotent error handling so the script can run safely even if some resources are already gone.
  • Run the teardown and verify all Knicks analytics resources are removed from IAM.
Create the teardown script

The teardown must happen in a specific sequence. Policies must be detached before groups can be deleted. Users must be removed from groups and have their login profiles and access keys deleted before the user itself can be removed.

  • Create a new file called teardown_iam.py in your knicks-iam-project folder.
  • Add the imports, configuration, and helper functions by pasting this code:
import boto3
from botocore.exceptions import ClientError

iam = boto3.client("iam")

GROUPS = ["ProductManagers", "AIEngineers", "DataAnalysts"]
USERS = ["knicks-pm-jordan", "knicks-ai-riley", "knicks-data-chen"]
CUSTOM_POLICY_NAME = "KnicksChampionshipDataPolicy"


def get_account_id():
    """Get the current AWS account ID."""
    sts = boto3.client("sts")
    return sts.get_caller_identity()["Account"]


def safe_execute(description, func, *args, **kwargs):
    """Execute a function and handle common errors gracefully."""
    try:
        func(*args, **kwargs)
        print(f"  OK: {description}")
    except ClientError as e:
        code = e.response["Error"]["Code"]
        if code in ("NoSuchEntity", "DeleteConflict"):
            print(f"  SKIP: {description} ({code})")
        else:
            print(f"  ERROR: {description} ({code})")

What does this code do?

The configuration lists the same group names, usernames, and policy name from your setup script. The script needs to know exactly what to delete.

The safe_execute() helper is the key to idempotent teardown. If a resource is already gone (NoSuchEntity) or has a dependency conflict (DeleteConflict), the script prints a SKIP message and continues instead of crashing.

  • Add the policy detachment function below safe_execute():
def detach_all_group_policies(group_name):
    """Detach all managed policies from a group."""
    try:
        response = iam.list_attached_group_policies(GroupName=group_name)
        for policy in response["AttachedPolicies"]:
            safe_execute(
                f"Detach {policy['PolicyName']} from {group_name}",
                iam.detach_group_policy,
                GroupName=group_name,
                PolicyArn=policy["PolicyArn"],
            )
    except ClientError:
        pass

What does this function do?

This function lists every managed policy attached to a group, then detaches each one. It must run BEFORE you delete the group, because IAM refuses to delete a group that still has policy attachments.

  • Add the user deletion function below detach_all_group_policies():
def delete_user_completely(username):
    """Remove a user from all groups, delete login profile, delete access keys, then delete user."""
    try:
        groups = iam.list_groups_for_user(UserName=username)
        for group in groups["Groups"]:
            safe_execute(
                f"Remove {username} from {group['GroupName']}",
                iam.remove_user_from_group,
                GroupName=group["GroupName"],
                UserName=username,
            )
    except ClientError:
        pass

    safe_execute(
        f"Delete login profile for {username}",
        iam.delete_login_profile,
        UserName=username,
    )

    try:
        keys = iam.list_access_keys(UserName=username)
        for key in keys["AccessKeyMetadata"]:
            safe_execute(
                f"Delete access key {key['AccessKeyId']}",
                iam.delete_access_key,
                UserName=username,
                AccessKeyId=key["AccessKeyId"],
            )
    except ClientError:
        pass

    safe_execute(f"Delete user {username}", iam.delete_user, UserName=username)

Why does user deletion require so many steps?

An IAM user has multiple attached resources: group memberships, a login profile, and possibly access keys. AWS will not delete a user until ALL of these are removed first. This function handles each dependency in order.

The access key cleanup is especially important. If your test script from the previous step left a dangling key (due to a crash), this function finds and removes it.

  • Add the main() function and entry point below delete_user_completely():
def main():
    print("=" * 50)
    print("Knicks Analytics Platform - IAM Teardown")
    print("=" * 50)

    account_id = get_account_id()
    custom_policy_arn = f"arn:aws:iam::{account_id}:policy/{CUSTOM_POLICY_NAME}"

    print("\n--- Detaching Group Policies ---")
    for group in GROUPS:
        detach_all_group_policies(group)

    print("\n--- Deleting Custom Policy ---")
    safe_execute(
        f"Delete policy {CUSTOM_POLICY_NAME}",
        iam.delete_policy,
        PolicyArn=custom_policy_arn,
    )

    print("\n--- Deleting Users ---")
    for user in USERS:
        delete_user_completely(user)

    print("\n--- Deleting Groups ---")
    for group in GROUPS:
        safe_execute(f"Delete group {group}", iam.delete_group, GroupName=group)

    print("\n--- Teardown Complete ---")


if __name__ == "__main__":
    main()

What does the main function do?

The main() function orchestrates the teardown in the correct dependency order: detach policies first, then delete the custom policy, then delete users (which removes their sub-resources), and finally delete the now-empty groups.

It dynamically constructs the custom policy ARN using your account ID, so the script works across different AWS accounts without hardcoding.

  • Save teardown_iam.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before running it.

ⓧ I'd like to double check the full code

import boto3
from botocore.exceptions import ClientError

iam = boto3.client("iam")

GROUPS = ["ProductManagers", "AIEngineers", "DataAnalysts"]
USERS = ["knicks-pm-jordan", "knicks-ai-riley", "knicks-data-chen"]
CUSTOM_POLICY_NAME = "KnicksChampionshipDataPolicy"


def get_account_id():
    """Get the current AWS account ID."""
    sts = boto3.client("sts")
    return sts.get_caller_identity()["Account"]


def safe_execute(description, func, *args, **kwargs):
    """Execute a function and handle common errors gracefully."""
    try:
        func(*args, **kwargs)
        print(f"  OK: {description}")
    except ClientError as e:
        code = e.response["Error"]["Code"]
        if code in ("NoSuchEntity", "DeleteConflict"):
            print(f"  SKIP: {description} ({code})")
        else:
            print(f"  ERROR: {description} ({code})")


def detach_all_group_policies(group_name):
    """Detach all managed policies from a group."""
    try:
        response = iam.list_attached_group_policies(GroupName=group_name)
        for policy in response["AttachedPolicies"]:
            safe_execute(
                f"Detach {policy['PolicyName']} from {group_name}",
                iam.detach_group_policy,
                GroupName=group_name,
                PolicyArn=policy["PolicyArn"],
            )
    except ClientError:
        pass


def delete_user_completely(username):
    """Remove a user from all groups, delete login profile, delete access keys, then delete user."""
    try:
        groups = iam.list_groups_for_user(UserName=username)
        for group in groups["Groups"]:
            safe_execute(
                f"Remove {username} from {group['GroupName']}",
                iam.remove_user_from_group,
                GroupName=group["GroupName"],
                UserName=username,
            )
    except ClientError:
        pass

    safe_execute(
        f"Delete login profile for {username}",
        iam.delete_login_profile,
        UserName=username,
    )

    try:
        keys = iam.list_access_keys(UserName=username)
        for key in keys["AccessKeyMetadata"]:
            safe_execute(
                f"Delete access key {key['AccessKeyId']}",
                iam.delete_access_key,
                UserName=username,
                AccessKeyId=key["AccessKeyId"],
            )
    except ClientError:
        pass

    safe_execute(f"Delete user {username}", iam.delete_user, UserName=username)


def main():
    print("=" * 50)
    print("Knicks Analytics Platform - IAM Teardown")
    print("=" * 50)

    account_id = get_account_id()
    custom_policy_arn = f"arn:aws:iam::{account_id}:policy/{CUSTOM_POLICY_NAME}"

    print("\n--- Detaching Group Policies ---")
    for group in GROUPS:
        detach_all_group_policies(group)

    print("\n--- Deleting Custom Policy ---")
    safe_execute(
        f"Delete policy {CUSTOM_POLICY_NAME}",
        iam.delete_policy,
        PolicyArn=custom_policy_arn,
    )

    print("\n--- Deleting Users ---")
    for user in USERS:
        delete_user_completely(user)

    print("\n--- Deleting Groups ---")
    for group in GROUPS:
        safe_execute(f"Delete group {group}", iam.delete_group, GroupName=group)

    print("\n--- Teardown Complete ---")


if __name__ == "__main__":
    main()
Understand the idempotent error handling pattern

The safe_execute() function implements a pattern called idempotent teardown. This means you can run the script multiple times and it produces the same end result every time, regardless of what has already been deleted.

Why does idempotent teardown matter?

In production, cleanup scripts often fail halfway through (network timeouts, permission issues, rate limits). When you re-run the script, some resources are already gone. Without idempotent handling, the script crashes on the first missing resource and leaves everything else behind.

The NoSuchEntity error code means the resource is already deleted. The DeleteConflict code means a dependency still exists. Both are handled gracefully so the script keeps running through the remaining resources.

Run the teardown and verify in the console
  • Run the teardown script by executing this command:
python3 teardown_iam.py

You should see output showing each resource being detached, deleted, or skipped. The final line reads --- Teardown Complete ---.

What should I see?

Each action prints either OK (resource successfully deleted), SKIP (resource already gone), or ERROR (unexpected problem). A successful run has all OK statuses with no ERROR lines.

  • Open the AWS Management Console in your browser.
  • Navigate to the IAM service.
  • Click User groups in the left sidebar. Confirm that ProductManagers, AIEngineers, and DataAnalysts are gone.
  • Click Users in the left sidebar. Confirm that knicks-pm-jordan, knicks-ai-riley, and knicks-data-chen are gone.
  • Click Policies in the left sidebar and search for KnicksChampionshipDataPolicy. Confirm it no longer appears.

Seeing ERROR lines in the output?

If you see ERROR (not SKIP) lines, your AWS credentials may lack the required IAM permissions. Make sure the credentials configured with aws configure have admin or IAM full access.

If you see a DeleteConflict error on a group, it means an attached policy was not detached properly. Run the script a second time. The idempotent pattern means re-running is always safe.

Help me debug ERROR lines in my teardown script output.

Secret mission

Simulate Policies with the IAM Policy Simulator API

In Step 4, you tested permissions by making real AWS calls and catching errors. Production security teams use a cleaner approach. The IAM Policy Simulator API lets you ask AWS "would this user be allowed to do X?" without actually doing it. Write a simulation script that validates all your policies programmatically.

Clean Up Your Resources

Clean Up Your Resources

All IAM resources in this project are completely free. There are no ongoing costs regardless of whether you keep them or delete them.

Decide whether to keep your resources for future learning, clean up the cloud resources while keeping your local code, or delete everything entirely.

Resources you used:

  • 3 IAM User Groups: ProductManagers, AIEngineers, DataAnalysts.
  • 3 IAM Users: knicks-pm-jordan, knicks-ai-riley, knicks-data-chen.
  • 1 Customer managed policy: KnicksChampionshipDataPolicy.
  • Local project folder: knicks-iam-project on your Desktop.
  • AWS CLI credentials stored in ~/.aws/credentials and ~/.aws/config.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep the IAM structure in place for future learning.

  • All IAM resources (groups, users, policies) are free and incur no charges.
  • Your AWS CLI and Boto3 installations remain on your machine for future AWS projects.
  • Your ~/.aws/credentials file is used by all future AWS CLI and Boto3 work. Do not delete it unless you want to reconfigure later.

Pause - I'll come back to this later

Remove the IAM resources from your AWS account while keeping your local code files as a portfolio piece.

  • Run the teardown script to remove all IAM cloud resources:
cd ~/Desktop/knicks-iam-project
python3 teardown_iam.py
  • Your local Python files (setup_iam.py, teardown_iam.py, test_permissions.py, simulate_policies.py, custom_policy.json) remain on your Desktop.
  • To recreate the IAM resources later, just run python3 setup_iam.py again.
  • Your AWS CLI credentials remain configured for future use.

Delete - I don't want to use this again

Remove all project resources from AWS and delete local files. Your machine goes back to the state before this project.

  • Run the teardown script to remove all IAM cloud resources:
cd ~/Desktop/knicks-iam-project
python3 teardown_iam.py
  • Delete the local project folder by running:
cd ~/Desktop
rm -rf knicks-iam-project
  • Remove AWS CLI credentials from your machine by running:
rm -rf ~/.aws
  • Uninstall the AWS CLI by running:
sudo rm /usr/local/bin/aws
sudo rm /usr/local/bin/aws_completer
sudo rm -rf /usr/local/aws-cli
  • Uninstall Boto3 by running:
pip3 uninstall boto3

What about your AWS account?

Your AWS account itself remains active. IAM resources are free, so there is nothing left from this project that could generate charges.

Nice Work!

Nice Work!

You just automated the entire IAM setup for the Knicks' championship analytics platform using Python and Boto3. Instead of clicking through the console, you wrote infrastructure-as-code that creates, tests, and tears down cloud access control programmatically.

You've learned how to:

  • Write a Python setup script using Boto3 that programmatically creates IAM groups, users, and policy attachments for a cross-functional team.
  • Create a custom IAM policy using policy variables (${aws:username}) and condition keys (aws:SecureTransport) for per-user S3 access scoping over HTTPS.
  • Test least-privilege access programmatically by catching AccessDenied errors in Python and building an idempotent teardown script with proper resource cleanup ordering.
  • Secret Mission: Used the IAM Policy Simulator API to validate policies without making real AWS calls.

Ready to quiz yourself?