← All posts

Docker Swarm і S3 в іншому акаунті: доступ без ключів

AWSIAMS3STSDocker Swarm

У нас є Swarm. У нас є S3 в іншому акаунті. У нас є single EC2 Instance Profile. Як зробити доступ без ключів і не відкрити все всім?

Гравюра: чоловік у плащі біля скрині із замком перед триголовим псом

Отже, звернулися до мене з таким питанням…

Маємо два AWS-акаунти:

  • Account A - приватний S3-бакет із даними.
  • Account B - EC2-інстанс із Docker Swarm і додатком.

Наразі додаток звертається до S3 за допомогою AWS access keys. У самому S3 налаштована policy, яка дозволяє доступ лише за умови:

  • що запит надходить із конкретного VPC (через aws:SourceVpc);
  • і що його виконує конкретний IAM principal (user + access keys).

Схема поточного стану: додаток у Swarm в Account B ходить до S3 в Account A з ключами IAM User Current Situation

Це класика: спочатку все зробили «тимчасово», аби лише запрацювало, - і забули.

Тепер настав час накрутити безпеку: позбутися AWS access keys і перейти на IAM Roles + STS для надійної, контрольованої взаємодії між акаунтами.

Мем із Кайло Реном: «I know what I have to do, but I don't know if I have the strength to do it» Let’s roll some security into it

Resolution

Створюємо окрему IAM role в Account A та дозволяємо її assume з боку Account B.

Здавалося б, просте завдання - у Kubernetes ми б давно це зробили через IRSA: прив’язали service account до pod, цей service account - до конкретної IAM-ролі, а далі - закрутили cross-account доступ.

Але тут у нас не K8s, а Docker Swarm - і саме тут починається проблемка.

Бо в Swarm усі ворклоади сидять на одній EC2-ролі (через Instance Profile). А IAM не вміє сам відрізнити, чи це саме твоя апка звертається до S3, чи сусідній контейнер, який випадково (чи ні) теж запущений на цьому ж EC2.

Немає ані service account’ів, ані fine-grained identity, як у Kubernetes. Усе, що бачить IAM, - це ec2.amazonaws.com із конкретною роллю. Тож доводиться викручуватися й закручувати додаткові обмеження.

🟡 Чому це не так просто, як здається

Здавалося б, додав Trust Policy для cross-account і забув:

"Principal": {
  "AWS": "arn:aws:iam::account_b:role/ec2-node-base-role"
}

Готово? Насправді - ні.

У такій конфігурації будь-який контейнер на цьому EC2 може виконати AssumeRole. Усі зможуть читати з S3 - навіть ті, хто не повинен. Отже, просто AssumeRole - це не separation of workloads. Це - дірка.

🛠 Що робити?

Мем із Морфеусом: «What if I told you I forgot what I wanted to say»

Потрібно ввести додатковий механізм розмежування. Тепер додаток має передавати додатковий payload під час виклику STS - щось, що дозволить унікально ідентифікувати саме цей ворклоад, навіть якщо він запускається на тій самій машині, що й інші.

🧩 sts:ExternalId, aws:RequestTag, SourceIdentity

І тут нам на допомогу приходить AWS STS. Саме під час виклику AssumeRole ми можемо передати додаткові атрибути, які дозволять ідентифікувати ворклоад.

Використовуємо:

  • sts:ExternalId - унікальний ідентифікатор, який перевіряється в trust policy ролі.
  • aws:RequestTag - теги, які передаються під час AssumeRole і потім використовуються в IAM role permissions policy через aws:PrincipalTag.
  • SourceIdentity - логічна мітка, що потрапляє в CloudTrail і додає прозорості викликам sts:AssumeRole.

🔍 Як це працює?

Схема цільового стану: контейнер у Swarm через роль EC2 асьюмить роль в Account A і працює з S3 якось так і працює))

На стороні додатка:

Аплікація викликає AssumeRole до ролі в Account A та передає в STS:

  • ExternalId = “my-application-secret-abc123”;
  • aws:RequestTag/AppId=“myapp-123”.

Trust policy ролі в Account A перевіряє, що:

  1. Запит надходить від очікуваної ролі з Account B.
  2. Передано правильні ExternalId та aws:RequestTag (наприклад, щоб унеможливити випадкове або зловмисне AssumeRole навіть із дозволеної ролі).
EC2 (Swarm workload) 
     | 
     |---> STS AssumeRole (sts:ExternalId, aws:RequestTag, SourceIdentity) | 
                |---> S3 bucket (Account A)

Важливо: ми не множимо IAM-ролі на кожен контейнер. Ми додаємо додаткові мітки фільтрації - ExternalId, RequestTag, - які відрізняють наш workload від інших.

🛠️ Нумо реалізувати

1) IAM Role «s3-access-role» в Account A - для доступу до S3

Ця роль може бути «assumed» тільки з Account B + має специфічні умови (ExternalId, RequestTag).

#trust_policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::account_b:role/ec2-node-base-role"
            },
            "Action": "sts:AssumeRole",
            "Condition": {
                "StringEquals": {
                    "aws:RequestTag/AppId": "myapp-123",
                    "sts:ExternalId": "my-application-secret-abc123"
                }
            }
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::account_b:role/ec2-node-base-role"
            },
            "Action": [
                "sts:SetSourceIdentity",
                "sts:TagSession"
            ]
        }
    ]
}

#permissions_json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::assumed-with-external-id",
                "arn:aws:s3:::assumed-with-external-id/*"
            ]
        }
    ]
}

sts:ExternalId - простий shared secret, щоб не було «випадкових» асьюмів.

aws:RequestTag/AppId - ключова річ для workload identity separation.

Також ми тут додаємо sts:SetSourceIdentity та sts:TagSession, щоб мати змогу прокидати це через STS via AWS SDK (boto3).

2) IAM Role в Account B - EC2 Instance Profile, яка буде асьюмити роль в Account A

#json policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "sts:AssumeRole",
                "sts:SetSourceIdentity",
                "sts:TagSession"
            ],
            "Resource": "arn:aws:iam::account_a:role/s3-access-role"
        }
    ]
}

#trust policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "ec2.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

Також ми тут додаємо sts:SetSourceIdentity та sts:TagSession, щоб мати змогу прокидати це через STS via AWS SDK (boto3).

3) S3 bucket assumed-with-external-id policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowObjectActions",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::account_a:role/s3-access-role"
      },
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::assumed-with-external-id/*",
      "Condition": {
        "IpAddress": {
          "aws:SourceIp": "172.17.1.0/24" #swarm vpc
        }
      }
    },
    {
      "Sid": "AllowListBucket",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::account_a:role/s3-access-role"
      },
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::assumed-with-external-id",
      "Condition": {
        "IpAddress": {
          "aws:SourceIp": "172.17.1.0/24"
        }
      }
    }
  ]
}

4) Boto3-виклик для STS AssumeRole

sts_client.assume_role(
    RoleArn='arn:aws:iam::account_a:role/s3-access-role',
    RoleSessionName='myapp-session',
    ExternalId='my-application-secret-abc123',
    Tags=[{'Key': 'AppId', 'Value': 'myapp-123'}],
    SourceIdentity='myapp-prod-container'
)

SourceIdentity - це просто нормальний лог у CloudTrail, де потім не доведеться згадувати: «а хто ж це був?».

5) S3 access з тимчасовими credentials

s3_client = boto3.client(
    's3',
    aws_access_key_id=credentials['AccessKeyId'],
    aws_secret_access_key=credentials['SecretAccessKey'],
    aws_session_token=credentials['SessionToken']
)
#operations
s3_client.put_object(
    Bucket='my-important-bucket',
    Key='uploads/file.txt',
    Body='Hello from AssumeRole'
)

Чудово! Тестуємо сценарій, коли «інший контейнер» намагається виконати AssumeRole без потрібних тегів. Очікуємо - AccessDenied.

✅ 1. Підготовка середовища

#in container on ec2
python3 -m venv /tmp/test
source /tmp/test/bin/activate
pip install boto3

✅ 2. Код для виклику STS AssumeRole без тегів

Все дуже просто.

  1. Викликаємо STS через boto3.
  2. Отримуємо AccessKeyId, SecretAccessKey і SessionToken.
  3. Ломимось до S3 - ніщо нас не зупиняє (поки не ввімкнемо перевірку за тегами, ExternalId, IP, VPC тощо).
#python

import boto3
import botocore.exceptions
import sys

def safe_print_secret(s):
    return s[:4] + '...' + s[-4:]

try:
    print("Assuming role with ExternalId...")

    sts_client = boto3.client('sts')
    response = sts_client.assume_role(
        RoleArn='arn:aws:iam::account_a:role/s3-access-role',
        RoleSessionName='my-app-session',
        ExternalId='my-application-secret-abc123',
        #commented to get access denied
        #Tags=[{'Key': 'AppId', 'Value': 'myapp-123'}], 
        #SourceIdentity='myapp-prod-container'
    )

    credentials = response['Credentials']
    print(f"✅ AssumeRole successful. AccessKeyId: {safe_print_secret(credentials['AccessKeyId'])}")

except botocore.exceptions.ClientError as e:
    print(f"❌ AssumeRole failed: {e}")
    sys.exit(1)

# Create S3 client with assumed role credentials
s3_client = boto3.client(
    's3',
    aws_access_key_id=credentials['AccessKeyId'],
    aws_secret_access_key=credentials['SecretAccessKey'],
    aws_session_token=credentials['SessionToken']
)

bucket_name = 'assumed-with-external-id'

# Validate access with ListBucket
try:
    print(f"Listing contents of bucket '{bucket_name}' to validate access...")

    response = s3_client.list_objects_v2(Bucket=bucket_name)
    print(f"✅ ListBucket successful. Found {response.get('KeyCount', 0)} objects.")

except botocore.exceptions.ClientError as e:
    print(f"❌ ListBucket failed: {e}")
    print("➡️ Check IAM policy and bucket policy for s3:ListBucket permissions.")
    sys.exit(1)

# Upload file to S3
upload_key = 'uploads/hello.txt'
upload_body = 'Hello from Account A using ExternalId!'

try:
    print(f"Uploading object '{upload_key}' to bucket '{bucket_name}'...")

    s3_client.put_object(
        Bucket=bucket_name,
        Key=upload_key,
        Body=upload_body
    )
    print(f"✅ PutObject successful.")

except botocore.exceptions.ClientError as e:
    print(f"❌ PutObject failed: {e}")
    print("➡️ Check IAM policy and bucket policy for s3:PutObject permissions.")
    sys.exit(1)

# Validate that object was uploaded with HeadObject
try:
    print(f"Validating uploaded object '{upload_key}' exists with HeadObject...")

    s3_client.head_object(Bucket=bucket_name, Key=upload_key)
    print(f"✅ HeadObject successful. Upload confirmed.")

except botocore.exceptions.ClientError as e:
    print(f"❌ HeadObject failed: {e}")
    print("➡️ Object might not exist, or permissions are missing for s3:HeadObject.")
    sys.exit(1)

# Download file from S3
download_key = 'uploads/hello.txt'

try:
    print(f"Downloading object '{download_key}' from bucket '{bucket_name}'...")

    response = s3_client.get_object(
        Bucket=bucket_name,
        Key=download_key
    )

    content = response['Body'].read().decode('utf-8')
    print(f"✅ GetObject successful. Content:\n{content}")

except botocore.exceptions.ClientError as e:
    print(f"❌ GetObject failed: {e}")
    print("➡️ Check if the object exists and if permissions allow s3:GetObject.")
    sys.exit(1)

✅ Очікуваний результат

❌ AssumeRole failed: An error occurred (AccessDenied) when calling the AssumeRole operation: User: arn:aws:sts::account_b:assumed-role/ec2-node-base-role/i-**** is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::account_a:role/s3-access-role

Тобто доступу ми не отримали, бо не передавали tags.

🔄 Супер, оновлюємо код - тепер імітуємо «правильний» ворклоад, який:

  • передає ExternalId;
  • додає RequestTags;
  • очікує успішний доступ до S3.
 #python

import boto3
import botocore.exceptions
import sys

def safe_print_secret(s):
    return s[:4] + '...' + s[-4:]

try:
    print("Assuming role with ExternalId...")

    sts_client = boto3.client('sts')
    response = sts_client.assume_role(
        RoleArn='arn:aws:iam::account_a:role/s3-access-role',
        RoleSessionName='my-app-session',
        ExternalId='my-application-secret-abc123',
        Tags=[{'Key': 'AppId', 'Value': 'myapp-123'}], 
        SourceIdentity='myapp-prod-container'
    )

    credentials = response['Credentials']
    print(f"✅ AssumeRole successful. AccessKeyId: {safe_print_secret(credentials['AccessKeyId'])}")

except botocore.exceptions.ClientError as e:
    print(f"❌ AssumeRole failed: {e}")
    sys.exit(1)

# Create S3 client with assumed role credentials
s3_client = boto3.client(
    's3',
    aws_access_key_id=credentials['AccessKeyId'],
    aws_secret_access_key=credentials['SecretAccessKey'],
    aws_session_token=credentials['SessionToken']
)

bucket_name = 'assumed-with-external-id'

# Validate access with ListBucket
try:
    print(f"Listing contents of bucket '{bucket_name}' to validate access...")

    response = s3_client.list_objects_v2(Bucket=bucket_name)
    print(f"✅ ListBucket successful. Found {response.get('KeyCount', 0)} objects.")

except botocore.exceptions.ClientError as e:
    print(f"❌ ListBucket failed: {e}")
    print("➡️ Check IAM policy and bucket policy for s3:ListBucket permissions.")
    sys.exit(1)

# Upload file to S3
upload_key = 'uploads/hello.txt'
upload_body = 'Hello from Account A using ExternalId!'

try:
    print(f"Uploading object '{upload_key}' to bucket '{bucket_name}'...")

    s3_client.put_object(
        Bucket=bucket_name,
        Key=upload_key,
        Body=upload_body
    )
    print(f"✅ PutObject successful.")

except botocore.exceptions.ClientError as e:
    print(f"❌ PutObject failed: {e}")
    print("➡️ Check IAM policy and bucket policy for s3:PutObject permissions.")
    sys.exit(1)

# Validate that object was uploaded with HeadObject
try:
    print(f"Validating uploaded object '{upload_key}' exists with HeadObject...")

    s3_client.head_object(Bucket=bucket_name, Key=upload_key)
    print(f"✅ HeadObject successful. Upload confirmed.")

except botocore.exceptions.ClientError as e:
    print(f"❌ HeadObject failed: {e}")
    print("➡️ Object might not exist, or permissions are missing for s3:HeadObject.")
    sys.exit(1)

# Download file from S3
download_key = 'uploads/hello.txt'

try:
    print(f"Downloading object '{download_key}' from bucket '{bucket_name}'...")

    response = s3_client.get_object(
        Bucket=bucket_name,
        Key=download_key
    )

    content = response['Body'].read().decode('utf-8')
    print(f"✅ GetObject successful. Content:\n{content}")

except botocore.exceptions.ClientError as e:
    print(f"❌ GetObject failed: {e}")
    print("➡️ Check if the object exists and if permissions allow s3:GetObject.")
    sys.exit(1)
(test) root@dcce9aeb44bb:/tmp# python3 test.py Assuming role with ExternalId...
 ✅ AssumeRole successful. AccessKeyId: ASIA...2PGO 
Listing contents of bucket 'assumed-with-external-id' to validate access...
 ✅ ListBucket successful. Found 1 objects. 
Uploading object 'uploads/hello.txt' to bucket 'assumed-with-external-id'... 
✅ PutObject successful. Validating uploaded object 'uploads/hello.txt' exists with HeadObject...
 ✅ HeadObject successful. Upload confirmed. Downloading object 'uploads/hello.txt' from bucket 'assumed-with-external-id'... 
✅ GetObject successful. Content: Hello from Account A using ExternalId!

Мем із Морфеусом: «What if I told you you are awesome» and this is 100% true

Посилання на документацію

🛡 Нагадую про мій збір для ЗСУ

Якщо сподобалось - підтримай збір гривнею 💛 https://lnkd.in/d_p4V4Z4

#AWSCommunity #DevOps01

https://t.me/devops_01_ua


Вперше опубліковано на LinkedIn 17 травня 2025 р.: оригінал.