Docker Swarm і S3 в іншому акаунті: доступ без ключів
У нас є 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).
Current Situation
Це класика: спочатку все зробили «тимчасово», аби лише запрацювало, - і забули.
Тепер настав час накрутити безпеку: позбутися AWS access keys і перейти на IAM Roles + STS для надійної, контрольованої взаємодії між акаунтами.
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. Це - дірка.
🛠 Що робити?

Потрібно ввести додатковий механізм розмежування. Тепер додаток має передавати додатковий 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.
🔍 Як це працює?
якось так і працює))
На стороні додатка:
Аплікація викликає 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, яка буде асьюмити роль в 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 без тегів
Все дуже просто.
- Викликаємо STS через boto3.
- Отримуємо AccessKeyId, SecretAccessKey і SessionToken.
- Ломимось до 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!
and this is 100% true
Посилання на документацію
- https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies-cross-account-resource-access.html
- https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition_operators.html#Conditions_String
- https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html
- https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html#condition-keys-requesttag
- https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_manage-assume.html
- https://boto3.amazonaws.com/v1/documentation/api/latest/index.html
🛡 Нагадую про мій збір для ЗСУ
Якщо сподобалось - підтримай збір гривнею 💛 https://lnkd.in/d_p4V4Z4
#AWSCommunity #DevOps01
Вперше опубліковано на LinkedIn 17 травня 2025 р.: оригінал.