No Keys, No Cry: як побудувати безпечний S3 cross-account доступ для Docker Swarm
Отже, звернулись до мене з таким питанням...
Маємо два 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 перевіряє, що:
- Запит надходить від очікуваної ролі з Account B.
- Переданий вірний 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, для перевірки — а хто ж це був.
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 без тегів
Все дуже просто=)

- Викликаємо STS через boto3.
- Отримуємо AccessKeyId, SecretAccessKey і SessionToken.
- Ломимось до 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!

Посилання на документацію
- docs.aws.amazon.com/...ount-resource-access.html
- docs.aws.amazon.com/...rs.html#Conditions_String
- docs.aws.amazon.com/...s_iam-condition-keys.html
- docs.aws.amazon.com/...condition-keys-requesttag
- docs.aws.amazon.com/..._roles_manage-assume.html
- boto3.amazonaws.com/...ion/api/latest/index.html
Доєднуйтесь до #DevOps01.

www.youtube.com/@DevOps01
www.linkedin.com/...-hrechanychenko-9221aa66
Сподобалась стаття автора? Підписуйтесь на його акаунт вгорі сторінки, щоб отримувати сповіщення про нові публікації на пошту.

4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів