No Keys, No Cry: як побудувати безпечний S3 cross-account доступ для Docker Swarm

💡 Усі статті, обговорення, новини про DevOps — в одному місці. Приєднуйтесь до DevOps спільноти!

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

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

  • Account A — S3-бакет із даними.
  • Account B — апка в Docker Swarm on EC2(не всі ще переїхали на кубери).

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

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

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

Тепер настав час підкрутити безпеку:

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

Resolution

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

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

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

В Swarm кластері — усі ворклоади сидять на одній EC2-ролі (через Instance Profile).

IAM+S3 Policy не зможе зрозуміти: це саме наша апка звертається до S3 чи сусідній контейнер, який випадково (чи ні) теж запущений на цьому ж EC2.

Немає ані service account’ів, ані fine-grained identity, як у K8s.

Усе, що бачить 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.

Це — дірка в поточній безпековій конфігурації.

Шо робити?

Потрібно ввести додатковий механізм ідентифікації.

Застосунок має передавати додатковий payload під час виклику STS — щось, що дозволить унікально ідентифікувати саме цей application, навіть якщо він запускається на тій самій EC2, що й інші.

sts:ExternalId, aws:RequestTag, SourceIdentity

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

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

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

Як це працює?

На стороні застосунку:

Аплікація викликає 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, яка буде assume IAM role в 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"
        }
    ]
}

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 — це для log entry в CloudTrail, для перевірки — а хто ж це був.

Де лопата? Питання з гумором для українців | TikTok

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 без тегів

Все дуже просто=)

So Simple GIFs | Tenor

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

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)

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

bash

❌ 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 AKA ChatGPT=)
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)
bash

(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!

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

Доєднуйтесь до #DevOps01.

I need you cap : one of my friend is doing his CO and I need some wholesome  memes to cheer him up. Thanks to all of you memelords how will help

www.youtube.com/@DevOps01
www.linkedin.com/...​-hrechanychenko-9221aa66

Сподобалась стаття автора? Підписуйтесь на його акаунт вгорі сторінки, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Через роль можно извлечь временные ключи. И что дальше?
Они все равно есть.

Андрій, дякую за коментар.

Так, ключі є — але вже не статичні.
Вони тимчасові, з обмеженням по часу та умовах AssumeRole через STS.
Теги можна брати з безпечного джерела для workload-ідентифікації.
Схему можна розвивати, але це краще, ніж static keys.
Гарного вечора!)

Підписатись на коментарі