tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.Check working directory and AWS CLI
ls /app && which aws && aws --version
/usr/local/bin/aws aws-cli/2.34.38 Python/3.14.4 Linux/6.1.158 exe/x86_64.ubuntu.24 [stdout] /usr/local/bin/aws aws-cli/2.34.38 Python/3.14.4 Linux/6.1.158 exe/x86_64.ubuntu.24
Inspect AWS env vars
echo "AWS_ENDPOINT_URL=$AWS_ENDPOINT_URL"; echo "AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION"; echo "LOCALSTACK_HOSTNAME=$LOCALSTACK_HOSTNAME"; env | grep -iE 'aws|localstack' | sort
AWS_ENDPOINT_URL=http://localstack:4566 AWS_DEFAULT_REGION=us-east-1 LOCALSTACK_HOSTNAME= AWS_ACCESS_KEY_ID=test AWS_DEFAULT_REGION=us-east-1 AWS_ENDPOINT_URL=http://localstack:4566 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test [stdout] AWS_ENDPOINT_URL=http://localstack:4566 AWS_DEFAULT_REGION=us-east-1 LOCALSTACK_HOSTNAME= AWS_ACCESS_KEY_ID=test AWS_DEFAULT_REGION=us-east-1 AWS_ENDPOINT_URL=http://localstack:4566 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test
Inspect /app and verify localstack reachability
ls -la /app/ 2>&1; echo "---"; aws sts get-caller-identity
total 8
drwxr-xr-x 2 root root 4096 Apr 29 17:37 .
drwxr-xr-x 1 root root 4096 Apr 29 17:38 ..
---
{
"UserId": "000000000000",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
[stdout]
total 8
drwxr-xr-x 2 root root 4096 Apr 29 17:37 .
drwxr-xr-x 1 root root 4096 Apr 29 17:38 ..
---
{
"UserId": "000000000000",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}Probe localstack capabilities
aws localstack info 2>&1 | head -30; echo "---"; aws --endpoint-url=http://localstack:4566 logs describe-log-groups --max-items 1 2>&1 | head -10
aws: [ERROR]: An error occurred (ParamValidation): argument command: Found invalid choice 'localstack'
usage: aws [options] <command> <subcommand> [<subcommand> ...] [parameters]
To see help text, you can run:
aws help
aws <command> help
aws <command> <subcommand> help
---
{
"logGroups": []
}
[stdout]
aws: [ERROR]: An error occurred (ParamValidation): argument command: Found invalid choice 'localstack'
usage: aws [options] <command> <subcommand> [<subcommand> ...] [parameters]
To see help text, you can run:
aws help
aws <command> help
aws <command> <subcommand> help
---
{
"logGroups": []
}Create working subdirs
mkdir -p /app/lambda /app/policies && ls /app
lambda policies [stdout] lambda policies
import json
import os
import re
import boto3
DATA_ROLE_ARN = os.environ["DATA_ROLE_ARN"]
ALLOWED_TENANTS = {"acme", "globex", "globex-eu"}
TENANT_PATTERN = re.compile(r"^[a-z][a-z0-9-]{1,40}$")
_sts = boto3.client("sts")
def _bad_request(msg):
return {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": msg}),
}
def lambda_handler(event, context):
qs = event.get("queryStringParameters") or {}
tenant = (qs.get("tenant") or "").strip()
if not tenant or not TENANT_PATTERN.match(tenant) or tenant not in ALLOWED_TENANTS:
return _bad_request("invalid tenant")
resp = _sts.assume_role(
RoleArn=DATA_ROLE_ARN,
RoleSessionName=f"tenant-{tenant}",
DurationSeconds=900,
Tags=[{"Key": "TenantID", "Value": tenant}],
TransitiveTagKeys=["TenantID"],
)
creds = resp["Credentials"]
body = {
"AccessKeyId": creds["AccessKeyId"],
"SecretAccessKey": creds["SecretAccessKey"],
"SessionToken": creds["SessionToken"],
"Expiration": creds["Expiration"].isoformat(),
"TenantID": tenant,
}
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(body),
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AssumeTenantDataRoleOnly",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::000000000000:role/TenantDataRole"
},
{
"Sid": "ScopedLogWriteToOwnLogGroup",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/tenant-token-vendor:*"
},
{
"Sid": "ScopedLogGroupCreate",
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/tenant-token-vendor"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VendorAssumeWithTenantTag",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/TenantTokenVendorRole"
},
"Action": [
"sts:AssumeRole",
"sts:TagSession"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/TenantID": ["acme", "globex", "globex-eu"]
},
"Null": {
"aws:RequestTag/TenantID": "false"
},
"ForAllValues:StringEquals": {
"sts:TransitiveTagKeys": ["TenantID"]
}
}
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TenantScopedItemAccess",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:BatchWriteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["${aws:PrincipalTag/TenantID}"],
"dynamodb:Attributes": ["TenantID", "OrderID", "amount"]
},
"StringEqualsIfExists": {
"dynamodb:Select": "SPECIFIC_ATTRIBUTES"
}
}
}
]
}
{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
Create KMS CMK + alias
KEY_ID=$(aws kms create-key --description "SaaS orders CMK" --key-usage ENCRYPT_DECRYPT --query KeyMetadata.KeyId --output text) && echo "KEY_ID=$KEY_ID" && aws kms create-alias --alias-name alias/saas-orders-cmk --target-key-id "$KEY_ID" && echo "alias created" && aws kms describe-key --key-id alias/saas-orders-cmk --query 'KeyMetadata.{KeyId:KeyId,Arn:Arn}'KEY_ID=1370f770-dbcd-4237-8dac-08c17fa4778b
alias created
{
"KeyId": "1370f770-dbcd-4237-8dac-08c17fa4778b",
"Arn": "arn:aws:kms:us-east-1:000000000000:key/1370f770-dbcd-4237-8dac-08c17fa4778b"
}
[stdout]
KEY_ID=1370f770-dbcd-4237-8dac-08c17fa4778b
alias created
{
"KeyId": "1370f770-dbcd-4237-8dac-08c17fa4778b",
"Arn": "arn:aws:kms:us-east-1:000000000000:key/1370f770-dbcd-4237-8dac-08c17fa4778b"
}Create SaasOrders table with CMK + enable PITR
aws dynamodb create-table \
--table-name SaasOrders \
--attribute-definitions AttributeName=TenantID,AttributeType=S AttributeName=OrderID,AttributeType=S \
--key-schema AttributeName=TenantID,KeyType=HASH AttributeName=OrderID,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--sse-specification Enabled=true,SSEType=KMS,KMSMasterKeyId=alias/saas-orders-cmk \
--query 'TableDescription.{Name:TableName,Status:TableStatus,SSE:SSEDescription}' && \
aws dynamodb wait table-exists --table-name SaasOrders && \
aws dynamodb update-continuous-backups --table-name SaasOrders \
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true \
--query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus'{
"Name": "SaasOrders",
"Status": "ACTIVE",
"SSE": {
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/alias/saas-orders-cmk"
}
}
"ENABLED"
[stdout]
{
"Name": "SaasOrders",
"Status": "ACTIVE",
"SSE": {
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/alias/saas-orders-cmk"
}
}
"ENABLED"Seed 6 items, 2 per tenant
for tenant in acme globex globex-eu; do
for i in 1 2; do
aws dynamodb put-item --table-name SaasOrders --item "{
\"TenantID\": {\"S\": \"$tenant\"},
\"OrderID\": {\"S\": \"ord-$i\"},
\"amount\": {\"N\": \"$((100 * i + ${#tenant}))\"},
\"password\": {\"S\": \"sek-$tenant-$i\"}
}" > /dev/null && echo "put $tenant/ord-$i"
done
done
aws dynamodb scan --table-name SaasOrders --select COUNT --query Countput acme/ord-1 put acme/ord-2 put globex/ord-1 put globex/ord-2 put globex-eu/ord-1 put globex-eu/ord-2 6 [stdout] put acme/ord-1 put acme/ord-2 put globex/ord-1 put globex/ord-2 put globex-eu/ord-1 put globex-eu/ord-2 6
Create vendor exec role + scoped inline policy
VENDOR_ROLE_ARN=$(aws iam create-role --role-name TenantTokenVendorRole \ --assume-role-policy-document file:///app/policies/vendor-trust.json \ --query 'Role.Arn' --output text) && echo "VENDOR_ROLE_ARN=$VENDOR_ROLE_ARN" && \ aws iam put-role-policy --role-name TenantTokenVendorRole \ --policy-name VendorScopedPolicy \ --policy-document file:///app/policies/vendor-inline.json && echo "vendor inline attached"
VENDOR_ROLE_ARN=arn:aws:iam::000000000000:role/TenantTokenVendorRole vendor inline attached [stdout] VENDOR_ROLE_ARN=arn:aws:iam::000000000000:role/TenantTokenVendorRole vendor inline attached
Create data role + scoped inline policy
DATA_ROLE_ARN=$(aws iam create-role --role-name TenantDataRole \ --assume-role-policy-document file:///app/policies/data-trust.json \ --query 'Role.Arn' --output text) && echo "DATA_ROLE_ARN=$DATA_ROLE_ARN" && \ aws iam put-role-policy --role-name TenantDataRole \ --policy-name TenantScopedDdb \ --policy-document file:///app/policies/data-inline.json && echo "data inline attached"
DATA_ROLE_ARN=arn:aws:iam::000000000000:role/TenantDataRole data inline attached [stdout] DATA_ROLE_ARN=arn:aws:iam::000000000000:role/TenantDataRole data inline attached
Package + create lambda
cd /app/lambda && zip -q /tmp/handler.zip handler.py && ls -la /tmp/handler.zip && \
aws logs create-log-group --log-group-name /aws/lambda/tenant-token-vendor 2>&1 || true && \
LAMBDA_ARN=$(aws lambda create-function \
--function-name tenant-token-vendor \
--runtime python3.11 \
--role arn:aws:iam::000000000000:role/TenantTokenVendorRole \
--handler handler.lambda_handler \
--zip-file fileb:///tmp/handler.zip \
--timeout 10 \
--environment "Variables={DATA_ROLE_ARN=arn:aws:iam::000000000000:role/TenantDataRole}" \
--query 'FunctionArn' --output text) && echo "LAMBDA_ARN=$LAMBDA_ARN" && \
aws lambda wait function-active --function-name tenant-token-vendor && echo "active"-rw-r--r-- 1 root root 776 Apr 29 17:41 /tmp/handler.zip LAMBDA_ARN=arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor active [stdout] -rw-r--r-- 1 root root 776 Apr 29 17:41 /tmp/handler.zip LAMBDA_ARN=arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor active
Smoke-test lambda directly
aws lambda invoke --function-name tenant-token-vendor \
--payload "$(echo '{"queryStringParameters":{"tenant":"acme"}}' | base64 -w0)" /tmp/out.json > /tmp/inv.json
cat /tmp/inv.json; echo "---"; cat /tmp/out.json{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
---
{"statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": "{\"AccessKeyId\": \"LSIAQAAAAAAAMCYAHT5N\", \"SecretAccessKey\": \"5HQlbTiA/GxECNgIwZfN4kz3BzIUs6Ov4s/cGKVP\", \"SessionToken\": \"FQoGZXIvYXdzEyR9Zqwx6p4B3I7XOh4sr7drE6fqiTA3XnjIuUn5yIXmwOLRhL7mP5khA90OfPyJVrmiYWHcaPOKI6jef7paC0izCEnEbnnlz263Qe0wt7oMV5sju1=h2myLVebj2Uob/dd8=HpFZyNJ6sp7FCKKCRd5SfbB4NAgentVQZPmTibbFX/o3So0SVrHcs8vIKHcAb4EhEQcL91CF1uXwo3YoXYVpzEsHus0d35AeU24obr0eCSTv7V6hbpHCZDjXq4UoTALNa/WYuTyhaDT=+SMYRG/AoJWvVHBRuQe6lAts0CG4f95wXS5jdHrQg5Z95JBq/LZzXedyDpmqhtJDRiHhn/u\", \"Expiration\": \"2026-04-29T17:56:57.317652+00:00\", \"TenantID\": \"acme\"}"}
[stdout]
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
---
{"statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": "{\"AccessKeyId\": \"LSIAQAAAAAAAMCYAHT5N\", \"SecretAccessKey\": \"5HQlbTiA/GxECNgIwZfN4kz3BzIUs6Ov4s/cGKVP\", \"SessionToken\": \"FQoGZXIvYXdzEyR9Zqwx6p4B3I7XOh4sr7drE6fqiTA3XnjIuUn5yIXmwOLRhL7mP5khA90OfPyJVrmiYWHcaPOKI6jef7paC0izCEnEbnnlz263Qe0wt7oMV5sju1=h2myLVebj2Uob/dd8=HpFZyNJ6sp7FCKKCRd5SfbB4NAgentVQZPmTibbFX/o3So0SVrHcs8vIKHcAb4EhEQcL91CF1uXwo3YoXYVpzEsHus0d35AeU24obr0eCSTv7V6hbpHCZDjXq4UoTALNa/WYuTyhaDT=+SMYRG/AoJWvVHBRuQe6lAts0CG4f95wXS5jdHrQg5Z95JBq/LZzXedyDpmqhtJDRiHhn/u\", \"Expiration\": \"2026-04-29T17:56:57.317652+00:00\", \"TenantID\": \"acme\"}"}Bad-input tenant validation tests
for t in 'evil' 'ACME' 'acme;rm' '' 'acme'; do
aws lambda invoke --function-name tenant-token-vendor \
--payload "$(printf '{"queryStringParameters":{"tenant":"%s"}}' "$t" | base64 -w0)" /tmp/o.json > /dev/null
printf 'tenant=%-12q -> ' "$t"; head -c 60 /tmp/o.json; echo
donetenant=evil -> {"statusCode": 400, "headers": {"Content-Type": "application
tenant=ACME -> {"statusCode": 400, "headers": {"Content-Type": "application
tenant=acme\;rm -> {"statusCode": 400, "headers": {"Content-Type": "application
tenant='' -> {"statusCode": 400, "headers": {"Content-Type": "application
tenant=acme -> {"statusCode": 200, "headers": {"Content-Type": "application
[stdout]
tenant=evil -> {"statusCode": 400, "headers": {"Content-Type": "application
tenant=ACME -> {"statusCode": 400, "headers": {"Content-Type": "application
tenant=acme\;rm -> {"statusCode": 400, "headers": {"Content-Type": "application
tenant='' -> {"statusCode": 400, "headers": {"Content-Type": "application
tenant=acme -> {"statusCode": 200, "headers": {"Content-Type": "applicationCreate REST API, /token resource, GET method + AWS_PROXY integration
API_ID=$(aws apigateway create-rest-api --name harbor-saas-api --endpoint-configuration types=REGIONAL --query id --output text) && echo "API_ID=$API_ID" ROOT_ID=$(aws apigateway get-resources --rest-api-id "$API_ID" --query 'items[?path==`/`].id' --output text) && echo "ROOT_ID=$ROOT_ID" TOKEN_RES_ID=$(aws apigateway create-resource --rest-api-id "$API_ID" --parent-id "$ROOT_ID" --path-part token --query id --output text) && echo "TOKEN_RES_ID=$TOKEN_RES_ID" aws apigateway put-method --rest-api-id "$API_ID" --resource-id "$TOKEN_RES_ID" --http-method GET --authorization-type NONE > /dev/null && echo "GET method created" aws apigateway put-integration --rest-api-id "$API_ID" --resource-id "$TOKEN_RES_ID" --http-method GET \ --type AWS_PROXY --integration-http-method POST \ --uri "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor/invocations" > /dev/null && echo "AWS_PROXY integration set" echo "API_ID=$API_ID" > /tmp/api.env
API_ID=tqliivxlfz ROOT_ID=s4k74g4ets TOKEN_RES_ID=kssgkefuyb GET method created AWS_PROXY integration set [stdout] API_ID=tqliivxlfz ROOT_ID=s4k74g4ets TOKEN_RES_ID=kssgkefuyb GET method created AWS_PROXY integration set
Add scoped lambda:InvokeFunction permission
API_ID=$(. /tmp/api.env; echo $API_ID)
SOURCE_ARN="arn:aws:execute-api:us-east-1:000000000000:${API_ID}/*/GET/token"
aws lambda add-permission --function-name tenant-token-vendor \
--statement-id apigw-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "$SOURCE_ARN" \
--query 'Statement' --output text >/dev/null && echo "invoke permission set with SourceArn=$SOURCE_ARN"
aws lambda get-policy --function-name tenant-token-vendor --query Policy --output text | python3 -m json.toolinvoke permission set with SourceArn=arn:aws:execute-api:us-east-1:000000000000:tqliivxlfz/*/GET/token
{
"Version": "2012-10-17",
"Id": "default",
"Statement": [
{
"Sid": "apigw-invoke",
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor",
"Principal": {
"Service": "apigateway.amazonaws.com"
},
"Condition": {
"ArnLike": {
"AWS:SourceArn": "arn:aws:execute-api:us-east-1:000000000000:tqliivxlfz/*/GET/token"
}
}
}
]
}
[stdout]
invoke permission set with SourceArn=arn:aws:execute-api:us-east-1:000000000000:tqliivxlfz/*/GET/token
{
"Version": "2012-10-17",
"Id": "default",
"Statement": [
{
"Sid": "apigw-invoke",
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor",
"Principal": {
"Service": "apigateway.amazonaws.com"
},
"Condition": {
"ArnLike": {
"AWS:SourceArn": "arn:aws:execute-api:us-east-1:000000000000:tqliivxlfz/*/GET/token"
}
}
}
]
}Deploy stage with access logs, redeploy after wiring change
API_ID=$(. /tmp/api.env; echo $API_ID)
ACCESS_LOG_GROUP="/aws/apigateway/harbor-saas-api/access"
aws logs create-log-group --log-group-name "$ACCESS_LOG_GROUP" 2>&1 || true
ACCESS_LOG_ARN="arn:aws:logs:us-east-1:000000000000:log-group:${ACCESS_LOG_GROUP}"
aws apigateway create-deployment --rest-api-id "$API_ID" --stage-name prod --query 'id' --output text && echo "deployed"
aws apigateway update-stage --rest-api-id "$API_ID" --stage-name prod \
--patch-operations \
"op=replace,path=/accessLogSettings/destinationArn,value=${ACCESS_LOG_ARN}" \
'op=replace,path=/accessLogSettings/format,value={"requestId":"$context.requestId","ip":"$context.identity.sourceIp","status":"$context.status","resourcePath":"$context.resourcePath","httpMethod":"$context.httpMethod"}' \
--query '{stage:stageName,access:accessLogSettings}' && echo "access logs configured"
aws apigateway create-deployment --rest-api-id "$API_ID" --stage-name prod --description "post-access-log redeploy" --query 'id' --output text && echo "redeployed"
INVOKE_URL="http://localstack:4566/_aws/execute-api/${API_ID}/prod/_user_request_/token"
echo "INVOKE_URL=$INVOKE_URL"feyltny6yl
deployed
aws: [ERROR]: An error occurred (ParamValidation): Error parsing parameter '--patch-operations': Expected: '=', received: '"' for input:
op=replace,path=/accessLogSettings/format,value={"requestId":"$context.requestId","ip":"$context.identity.sourceIp","status":"$context.status","resourcePath":"$context.resourcePath","httpMethod":"$context.httpMethod"}
^
w5lnaeotwa
redeployed
INVOKE_URL=http://localstack:4566/_aws/execute-api/tqliivxlfz/prod/_user_request_/token
[stdout]
feyltny6yl
deployed
aws: [ERROR]: An error occurred (ParamValidation): Error parsing parameter '--patch-operations': Expected: '=', received: '"' for input:
op=replace,path=/accessLogSettings/format,value={"requestId":"$context.requestId","ip":"$context.identity.sourceIp","status":"$context.status","resourcePath":"$context.resourcePath","httpMethod":"$context.httpMethod"}
^
w5lnaeotwa
redeployed
INVOKE_URL=http://localstack:4566/_aws/execute-api/tqliivxlfz/prod/_user_request_/token[
{"op": "replace", "path": "/accessLogSettings/destinationArn", "value": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api/access"},
{"op": "replace", "path": "/accessLogSettings/format", "value": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"status\":\"$context.status\",\"resourcePath\":\"$context.resourcePath\",\"httpMethod\":\"$context.httpMethod\"}"}
]
Apply access-log settings and redeploy
API_ID=$(. /tmp/api.env; echo $API_ID)
aws apigateway update-stage --rest-api-id "$API_ID" --stage-name prod \
--patch-operations file:///tmp/access-log-patch.json \
--query '{stage:stageName,access:accessLogSettings}' && \
aws apigateway create-deployment --rest-api-id "$API_ID" --stage-name prod --description "post-access-log redeploy" --query 'id' --output text && echo "redeployed-final"{
"stage": "prod",
"access": {
"format": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"status\":\"$context.status\",\"resourcePath\":\"$context.resourcePath\",\"httpMethod\":\"$context.httpMethod\"}",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api/access"
}
}
ft6gskkxc1
redeployed-final
[stdout]
{
"stage": "prod",
"access": {
"format": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"status\":\"$context.status\",\"resourcePath\":\"$context.resourcePath\",\"httpMethod\":\"$context.httpMethod\"}",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api/access"
}
}
ft6gskkxc1
redeployed-finalWrite SSM pointers
API_ID=$(. /tmp/api.env; echo $API_ID)
INVOKE_URL="http://localstack:4566/_aws/execute-api/${API_ID}/prod/_user_request_/token"
aws ssm put-parameter --name /harbor/saas/table-name --type String --value SaasOrders --overwrite >/dev/null
aws ssm put-parameter --name /harbor/saas/role-arn --type String --value arn:aws:iam::000000000000:role/TenantDataRole --overwrite >/dev/null
aws ssm put-parameter --name /harbor/saas/vendor-role-arn --type String --value arn:aws:iam::000000000000:role/TenantTokenVendorRole --overwrite >/dev/null
aws ssm put-parameter --name /harbor/saas/lambda-arn --type String --value arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor --overwrite >/dev/null
aws ssm put-parameter --name /harbor/saas/api-id --type String --value "$API_ID" --overwrite >/dev/null
aws ssm put-parameter --name /harbor/saas/api-url --type String --value "$INVOKE_URL" --overwrite >/dev/null
aws ssm get-parameters-by-path --path /harbor/saas --query 'Parameters[].{N:Name,V:Value}'[
{
"N": "/harbor/saas/table-name",
"V": "SaasOrders"
},
{
"N": "/harbor/saas/role-arn",
"V": "arn:aws:iam::000000000000:role/TenantDataRole"
},
{
"N": "/harbor/saas/vendor-role-arn",
"V": "arn:aws:iam::000000000000:role/TenantTokenVendorRole"
},
{
"N": "/harbor/saas/lambda-arn",
"V": "arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor"
},
{
"N": "/harbor/saas/api-id",
"V": "tqliivxlfz"
},
{
"N": "/harbor/saas/api-url",
"V": "http://localstack:4566/_aws/execute-api/tqliivxlfz/prod/_user_request_/token"
}
]
[stdout]
[
{
"N": "/harbor/saas/table-name",
"V": "SaasOrders"
},
{
"N": "/harbor/saas/role-arn",
"V": "arn:aws:iam::000000000000:role/TenantDataRole"
},
{
"N": "/harbor/saas/vendor-role-arn",
"V": "arn:aws:iam::000000000000:role/TenantTokenVendorRole"
},
{
"N": "/harbor/saas/lambda-arn",
"V": "arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor"
},
{
"N": "/harbor/saas/api-id",
"V": "tqliivxlfz"
},
{
"N": "/harbor/saas/api-url",
"V": "http://localstack:4566/_aws/execute-api/tqliivxlfz/prod/_user_request_/token"
}
]"""End-to-end verifier:
1. GET /token?tenant=acme through API Gateway (REST v1).
2. Use returned creds to Query own tenant rows -> succeed (no password).
3. Use returned creds to Query a different tenant -> must deny at runtime.
4. Use returned creds to Query own tenant *with* password attribute requested
-> must deny at runtime (attribute-level lockout).
"""
import json
import sys
import urllib.request
import boto3
from botocore.exceptions import ClientError
ssm = boto3.client("ssm")
api_url = ssm.get_parameter(Name="/harbor/saas/api-url")["Parameter"]["Value"]
table = ssm.get_parameter(Name="/harbor/saas/table-name")["Parameter"]["Value"]
def step(label):
print(f"\n=== {label} ===")
step(f"GET {api_url}?tenant=acme")
with urllib.request.urlopen(f"{api_url}?tenant=acme") as r:
payload = json.loads(r.read())
print("status:", "200 (creds present)" if payload.get("AccessKeyId") else "FAIL")
print("tenant tag:", payload["TenantID"])
print("expires:", payload["Expiration"])
ddb = boto3.client(
"dynamodb",
aws_access_key_id=payload["AccessKeyId"],
aws_secret_access_key=payload["SecretAccessKey"],
aws_session_token=payload["SessionToken"],
)
step("same-tenant Query (acme) with safe attributes , must SUCCEED")
try:
res = ddb.query(
TableName=table,
KeyConditionExpression="TenantID = :t",
ExpressionAttributeValues={":t": {"S": "acme"}},
Select="SPECIFIC_ATTRIBUTES",
ProjectionExpression="TenantID, OrderID, amount",
)
print(f"got {res['Count']} items, first = {res['Items'][0] if res['Items'] else None}")
if any("password" in i for i in res["Items"]):
print("FAIL: password leaked")
sys.exit(1)
except ClientError as e:
print("FAIL same-tenant denied:", e.response["Error"]["Code"], e.response["Error"]["Message"])
sys.exit(1)
step("cross-tenant Query (acme creds, ask for globex) , must DENY")
try:
res = ddb.query(
TableName=table,
KeyConditionExpression="TenantID = :t",
ExpressionAttributeValues={":t": {"S": "globex"}},
Select="SPECIFIC_ATTRIBUTES",
ProjectionExpression="TenantID, OrderID, amount",
)
print("FAIL: cross-tenant Query returned", res.get("Count"), "items")
sys.exit(1)
except ClientError as e:
print("denied as expected:", e.response["Error"]["Code"])
step("password attribute on own tenant , must DENY")
try:
res = ddb.query(
TableName=table,
KeyConditionExpression="TenantID = :t",
ExpressionAttributeValues={":t": {"S": "acme"}},
Select="SPECIFIC_ATTRIBUTES",
ProjectionExpression="TenantID, OrderID, password",
)
print("FAIL: password Query succeeded:", res.get("Items"))
sys.exit(1)
except ClientError as e:
print("denied as expected:", e.response["Error"]["Code"])
print("\nALL CHECKS PASSED")
Run end-to-end verifier
cd /app && python3 verify.py
Exit code 1
=== GET http://localstack:4566/_aws/execute-api/tqliivxlfz/prod/_user_request_/token?tenant=acme ===
status: 200 (creds present)
tenant tag: acme
expires: 2026-04-29T17:58:51.538397+00:00
=== same-tenant Query (acme) with safe attributes , must SUCCEED ===
got 2 items, first = {'OrderID': {'S': 'ord-1'}, 'TenantID': {'S': 'acme'}, 'amount': {'N': '104'}}
=== cross-tenant Query (acme creds, ask for globex) , must DENY ===
FAIL: cross-tenant Query returned 2 items
[error] tool reported failureInspect LocalStack ENFORCE_IAM state
env | grep -iE 'enforce|iam|localstack' | sort
echo "---"
curl -s http://localstack:4566/_localstack/info 2>&1 | head -40 || true
echo "---"
curl -s http://localstack:4566/_localstack/config 2>&1 | python3 -c "import json,sys; c=json.load(sys.stdin); print(json.dumps({k:v for k,v in c.items() if 'iam' in k.lower() or 'enforce' in k.lower()}, indent=2))" 2>&1 | head -40 || trueAWS_ENDPOINT_URL=http://localstack:4566
---
{"version": "2026.3.0:e28f8cfa4", "edition": "pro", "is_license_activated": true, "session_id": "1cdcd9d9-3560-4d34-8fc7-3082cebcdd60", "machine_id": "dkr_dc773c0a2fb6", "system": "Ubuntu 24.04.4 LTS,6.1.158,x86_64", "is_docker": true, "server_time_utc": "2026-04-29T17:44:00", "uptime": 342}---
Traceback (most recent call last):
File "<string>", line 1, in <module>
File "/usr/lib/python3.12/json/__init__.py", line 293, in load
return loads(fp.read(),
^^^^^^^^^^^^^^^^
File "/usr/lib/python3.12/json/__init__.py", line 346, in loads
return _default_decoder.decode(s)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.12/json/decoder.py", line 337, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.12/json/decoder.py", line 355, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
[stdout]
AWS_ENDPOINT_URL=http://localstack:4566
---
{"version": "2026.3.0:e28f8cfa4", "edition": "pro", "is_license_activated": true, "session_id": "1cdcd9d9-3560-4d34-8fc7-3082cebcdd60", "machine_id": "dkr_dc773c0a2fb6", "system": "Ubuntu 24.04.4 LTS,6.1.158,x86_64", "is_docker": true, "server_time_utc": "2026-04-29T17:44:00", "uptime": 342}---
Traceback (most recent call last):
File "<string>", line 1, in <module>
File "/usr/lib/python3.12/json/__init__.py", line 293, in load
return loads(fp.read(),
^^^^^^^^^^^^^^^^
File "/usr/lib/python3.12/json/__init__.py", line 346, in loads
return _default_decoder.decode(s)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.12/json/decoder.py", line 337, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.12/json/decoder.py", line 355, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)Fetch LocalStack config
curl -s http://localstack:4566/_localstack/config | head -200
(Bash completed with no output)
Probe alternate localstack diagnostics
curl -sv http://localstack:4566/_localstack/diagnose 2>&1 | tail -30 echo "---" curl -s http://localstack:4566/_localstack/health 2>&1 | head -40
* Host localstack:4566 was resolved.
* IPv6: (none)
* IPv4: 172.18.0.2
* Trying 172.18.0.2:4566...
* Connected to localstack (172.18.0.2) port 4566
> GET /_localstack/diagnose HTTP/1.1
> Host: localstack:4566
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 404 NOT FOUND
< Server: TwistedWeb/25.5.0
< Date: Wed, 29 Apr 2026 17:44:06 GMT
< Content-Type: text/plain; charset=utf-8
< x-localstack: true
< Content-Length: 0
<
* Connection #0 to host localstack left intact
---
{"features": {"persistence": "disabled"}, "services": {"acm": "disabled", "apigateway": "running", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "running", "dynamodbstreams": "available", "ec2": "disabled", "es": "disabled", "events": "disabled", "firehose": "disabled", "iam": "running", "kinesis": "available", "kms": "running", "lambda": "running", "logs": "running", "opensearch": "disabled", "redshift": "disabled", "resource-groups": "disabled", "resourcegroupstaggingapi": "disabled", "route53": "disabled", "route53resolver": "disabled", "s3": "running", "s3control": "disabled", "scheduler": "disabled", "secretsmanager": "available", "ses": "disabled", "sns": "disabled", "sqs": "disabled", "ssm": "running", "stepfunctions": "disabled", "sts": "running", "support": "disabled", "swf": "disabled", "transcribe": "disabled", "account": "disabled", "acm-pca": "disabled", "amplify": "disabled", "apigatewaymanagementapi": "disabled", "apigatewayv2": "running", "appconfig": "disabled", "appconfigdata": "disabled", "application-autoscaling": "disabled", "appsync": "disabled", "athena": "disabled", "autoscaling": "disabled", "backup": "disabled", "batch": "disabled", "bedrock-runtime": "disabled", "bedrock": "disabled", "ce": "disabled", "cloudcontrol": "disabled", "cloudfront": "disabled", "cloudtrail": "disabled", "codeartifact": "disabled", "codebuild": "disabled", "codecommit": "disabled", "codeconnections": "disabled", "codedeploy": "disabled", "codepipeline": "disabled", "codestar-connections": "disabled", "cognito-identity": "disabled", "cognito-idp": "disabled", "dms": "disabled", "docdb": "disabled", "ecr": "disabled", "ecs": "disabled", "efs": "disabled", "eks-auth": "disabled", "eks": "disabled", "elasticache": "disabled", "elasticbeanstalk": "disabled", "elb": "disabled", "elbv2": "disabled", "emr-serverless": "disabled", "emr": "disabled", "fis": "disabled", "glacier": "disabled", "glue": "disabled", "identitystore": "disabled", "iot-data": "disabled", "iot": "disabled", "iotwireless": "disabled", "kafka": "disabled", "kinesisanalyticsv2": "disabled", "lakeformation": "disabled", "managedblockchain": "disabled", "mediaconvert": "disabled", "memorydb": "disabled", "mq": "disabled", "mwaa": "disabled", "neptune": "disabled", "organizations": "disabled", "pinpoint": "disabled", "pipes": "disabled", "ram": "disabled", "rds-data": "disabled", "rds": "disabled", "redshift-data": "disabled", "s3tables": "disabled", "sagemaker-runtime": "disabled", "sagemaker": "disabled", "serverlessrepo": "disabled", "servicediscovery": "disabled", "sesv2": "disabled", "shield": "disabled", "sso-admin": "disabled", "textract": "disabled", "timestream-query": "disabled", "timestream-write": "disabled", "transfer": "disabled", "verifiedpermissions": "disabled", "wafv2": "disabled", "xray": "disabled"}, "edition": "pro", "version": "2026.3.0"}
[stdout]
* Host localstack:4566 was resolved.
* IPv6: (none)
* IPv4: 172.18.0.2
* Trying 172.18.0.2:4566...
* Connected to localstack (172.18.0.2) port 4566
> GET /_localstack/diagnose HTTP/1.1
> Host: localstack:4566
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 404 NOT FOUND
< Server: TwistedWeb/25.5.0
< Date: Wed, 29 Apr 2026 17:44:06 GMT
< Content-Type: text/plain; charset=utf-8
< x-localstack: true
< Content-Length: 0
<
* Connection #0 to host localstack left intact
---
{"features": {"persistence": "disabled"}, "services": {"acm": "disabled", "apigateway": "running", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "running", "dynamodbstreams": "available", "ec2": "disabled", "es": "disabled", "events": "disabled", "firehose": "disabled", "iam": "running", "kinesis": "available", "kms": "running", "lambda": "running", "logs": "running", "opensearch": "disabled", "redshift": "disabled", "resource-groups": "disabled", "resourcegroupstaggingapi": "disabled", "route53": "disabled", "route53resolver": "disabled", "s3": "running", "s3control": "disabled", "scheduler": "disabled", "secretsmanager": "available", "ses": "disabled", "sns": "disabled", "sqs": "disabled", "ssm": "running", "stepfunctions": "disabled", "sts": "running", "support": "disabled", "swf": "disabled", "transcribe": "disabled", "account": "disabled", "acm-pca": "disabled", "amplify": "disabled", "apigatewaymanagementapi": "disabled", "apigatewayv2": "running", "appconfig": "disabled", "appconfigdata": "disabled", "application-autoscaling": "disabled", "appsync": "disabled", "athena": "disabled", "autoscaling": "disabled", "backup": "disabled", "batch": "disabled", "bedrock-runtime": "disabled", "bedrock": "disabled", "ce": "disabled", "cloudcontrol": "disabled", "cloudfront": "disabled", "cloudtrail": "disabled", "codeartifact": "disabled", "codebuild": "disabled", "codecommit": "disabled", "codeconnections": "disabled", "codedeploy": "disabled", "codepipeline": "disabled", "codestar-connections": "disabled", "cognito-identity": "disabled", "cognito-idp": "disabled", "dms": "disabled", "docdb": "disabled", "ecr": "disabled", "ecs": "disabled", "efs": "disabled", "eks-auth": "disabled", "eks": "disabled", "elasticache": "disabled", "elasticbeanstalk": "disabled", "elb": "disabled", "elbv2": "disabled", "emr-serverless": "disabled", "emr": "disabled", "fis": "disabled", "glacier": "disabled", "glue": "disabled", "identitystore": "disabled", "iot-data": "disabled", "iot": "disabled", "iotwireless": "disabled", "kafka": "disabled", "kinesisanalyticsv2": "disabled", "lakeformation": "disabled", "managedblockchain": "disabled", "mediaconvert": "disabled", "memorydb": "disabled", "mq": "disabled", "mwaa": "disabled", "neptune": "disabled", "organizations": "disabled", "pinpoint": "disabled", "pipes": "disabled", "ram": "disabled", "rds-data": "disabled", "rds": "disabled", "redshift-data": "disabled", "s3tables": "disabled", "sagemaker-runtime": "disabled", "sagemaker": "disabled", "serverlessrepo": "disabled", "servicediscovery": "disabled", "sesv2": "disabled", "shield": "disabled", "sso-admin": "disabled", "textract": "disabled", "timestream-query": "disabled", "timestream-write": "disabled", "transfer": "disabled", "verifiedpermissions": "disabled", "wafv2": "disabled", "xray": "disabled"}, "edition": "pro", "version": "2026.3.0"}Verify assumed-role identity from API-issued creds
cd /app && python3 -c "
import json, urllib.request, boto3
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
with urllib.request.urlopen(api+'?tenant=acme') as r:
p = json.loads(r.read())
print('TenantID:', p['TenantID'])
sts = boto3.client('sts',
aws_access_key_id=p['AccessKeyId'],
aws_secret_access_key=p['SecretAccessKey'],
aws_session_token=p['SessionToken'])
print('caller:', sts.get_caller_identity())
"
TenantID: acme
caller: {'UserId': 'AROAQAAAAAAAIZ4VCKG6F:tenant-acme', 'Account': '000000000000', 'Arn': 'arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme', 'ResponseMetadata': {'RequestId': '4a4f1f27-dd79-4024-8083-2f14b3de2560', 'HTTPStatusCode': 200, 'HTTPHeaders': {'server': 'TwistedWeb/25.5.0', 'date': 'Wed, 29 Apr 2026 17:44:18 GMT', 'content-type': 'text/xml', 'content-length': '448', 'x-amzn-requestid': '4a4f1f27-dd79-4024-8083-2f14b3de2560', 'x-localstack': 'true'}, 'RetryAttempts': 0}}
[stdout]
TenantID: acme
caller: {'UserId': 'AROAQAAAAAAAIZ4VCKG6F:tenant-acme', 'Account': '000000000000', 'Arn': 'arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme', 'ResponseMetadata': {'RequestId': '4a4f1f27-dd79-4024-8083-2f14b3de2560', 'HTTPStatusCode': 200, 'HTTPHeaders': {'server': 'TwistedWeb/25.5.0', 'date': 'Wed, 29 Apr 2026 17:44:18 GMT', 'content-type': 'text/xml', 'content-length': '448', 'x-amzn-requestid': '4a4f1f27-dd79-4024-8083-2f14b3de2560', 'x-localstack': 'true'}, 'RetryAttempts': 0}}Probe whether IAM enforcement is actually live
python3 -c "
import json, urllib.request, boto3
from botocore.exceptions import ClientError
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
with urllib.request.urlopen(api+'?tenant=acme') as r: p = json.loads(r.read())
kw = dict(aws_access_key_id=p['AccessKeyId'], aws_secret_access_key=p['SecretAccessKey'], aws_session_token=p['SessionToken'])
# 1. action that's not in policy at all (CreateTable)
ddb = boto3.client('ddb' if False else 'dynamodb', **kw)
try:
ddb.create_table(TableName='evil', AttributeDefinitions=[{'AttributeName':'pk','AttributeType':'S'}], KeySchema=[{'AttributeName':'pk','KeyType':'HASH'}], BillingMode='PAY_PER_REQUEST')
print('CreateTable: ALLOWED (BAD - means enforcement off)')
except ClientError as e:
print('CreateTable denied:', e.response['Error']['Code'])
# 2. action allowed in scope (Query own tenant)
try:
r = ddb.query(TableName='SaasOrders', KeyConditionExpression='TenantID = :t', ExpressionAttributeValues={':t':{'S':'acme'}})
print('Query own:', r['Count'])
except ClientError as e:
print('Query own denied:', e.response['Error']['Code'], e.response['Error']['Message'])
# 3. KMS DescribeKey - definitely outside policy
kms = boto3.client('kms', **kw)
try:
kms.list_keys(Limit=1)
print('KMS list_keys: ALLOWED (enforcement OFF)')
except ClientError as e:
print('KMS denied:', e.response['Error']['Code'])
"CreateTable denied: AccessDeniedException Query own: 2 KMS denied: AccessDeniedException [stdout] CreateTable denied: AccessDeniedException Query own: 2 KMS denied: AccessDeniedException
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TenantScopedItemAccessLITERAL",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:BatchWriteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["acme"]
}
}
}
]
}
Test with literal LeadingKeys=[acme]
aws iam put-role-policy --role-name TenantDataRole --policy-name TenantScopedDdb --policy-document file:///tmp/data-inline-literal.json
sleep 1
python3 -c "
import json, urllib.request, boto3
from botocore.exceptions import ClientError
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
with urllib.request.urlopen(api+'?tenant=acme') as r: p=json.loads(r.read())
ddb = boto3.client('dynamodb', aws_access_key_id=p['AccessKeyId'], aws_secret_access_key=p['SecretAccessKey'], aws_session_token=p['SessionToken'])
for t in ['acme','globex']:
try:
r=ddb.query(TableName='SaasOrders', KeyConditionExpression='TenantID = :t', ExpressionAttributeValues={':t':{'S':t}})
print(f'{t}: {r[\"Count\"]} items')
except ClientError as e:
print(f'{t}: denied -> {e.response[\"Error\"][\"Code\"]}')
"acme: 2 items globex: 2 items [stdout] acme: 2 items globex: 2 items
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyEverythingDDB",
"Effect": "Deny",
"Action": "dynamodb:*",
"Resource": "*"
},
{
"Sid": "AllowEverythingDDB",
"Effect": "Allow",
"Action": "dynamodb:*",
"Resource": "*"
}
]
}
Sanity-check IAM Deny on Query
aws iam put-role-policy --role-name TenantDataRole --policy-name TenantScopedDdb --policy-document file:///tmp/data-inline-deny-test.json
sleep 1
python3 -c "
import json, urllib.request, boto3
from botocore.exceptions import ClientError
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
with urllib.request.urlopen(api+'?tenant=acme') as r: p=json.loads(r.read())
ddb = boto3.client('dynamodb', aws_access_key_id=p['AccessKeyId'], aws_secret_access_key=p['SecretAccessKey'], aws_session_token=p['SessionToken'])
try:
r=ddb.query(TableName='SaasOrders', KeyConditionExpression='TenantID = :t', ExpressionAttributeValues={':t':{'S':'acme'}})
print('acme allowed (BAD - explicit Deny was ignored): count=', r['Count'])
except ClientError as e:
print('acme denied as expected:', e.response['Error']['Code'])
"acme denied as expected: AccessDeniedException [stdout] acme denied as expected: AccessDeniedException
Inspect localstack runtime config
curl -s http://localstack:4566/_localstack/config 2>/dev/null echo "---" curl -s http://localstack:4566/_localstack/state 2>/dev/null | head -200
--- [stdout] ---
Probe localstack diagnostic endpoints
curl -s -X POST http://localstack:4566/_localstack/diagnose -H 'Content-Type: application/json' 2>&1 | head -1 echo "---" # Try grep on localstack config endpoints for path in /_localstack/config /_localstack/iam/config /_aws/config /_localstack/cors; do echo "$path:"; curl -sI "http://localstack:4566$path" 2>&1 | head -2 done
--- /_localstack/config: HTTP/1.1 404 NOT FOUND Server: TwistedWeb/25.5.0 /_localstack/iam/config: HTTP/1.1 404 NOT FOUND Server: TwistedWeb/25.5.0 /_aws/config: HTTP/1.1 404 NOT FOUND Server: TwistedWeb/25.5.0 /_localstack/cors: HTTP/1.1 404 NOT FOUND Server: TwistedWeb/25.5.0 [stdout] --- /_localstack/config: HTTP/1.1 404 NOT FOUND Server: TwistedWeb/25.5.0 /_localstack/iam/config: HTTP/1.1 404 NOT FOUND Server: TwistedWeb/25.5.0 /_aws/config: HTTP/1.1 404 NOT FOUND Server: TwistedWeb/25.5.0 /_localstack/cors: HTTP/1.1 404 NOT FOUND Server: TwistedWeb/25.5.0
Try StringEquals (scalar) form
# try both: scalar StringEquals (in case LocalStack treats it as scalar)
cat > /tmp/data-inline-stringeq.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TenantScopedStrEq",
"Effect": "Allow",
"Action": ["dynamodb:Query","dynamodb:GetItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"StringEquals": {
"dynamodb:LeadingKeys": "${aws:PrincipalTag/TenantID}"
}
}
}
]
}
EOF
aws iam put-role-policy --role-name TenantDataRole --policy-name TenantScopedDdb --policy-document file:///tmp/data-inline-stringeq.json
sleep 1
python3 -c "
import json, urllib.request, boto3
from botocore.exceptions import ClientError
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
with urllib.request.urlopen(api+'?tenant=acme') as r: p=json.loads(r.read())
ddb = boto3.client('dynamodb', aws_access_key_id=p['AccessKeyId'], aws_secret_access_key=p['SecretAccessKey'], aws_session_token=p['SessionToken'])
for t in ['acme','globex']:
try:
r=ddb.query(TableName='SaasOrders', KeyConditionExpression='TenantID = :t', ExpressionAttributeValues={':t':{'S':t}})
print(f'{t}: count={r[\"Count\"]}')
except ClientError as e:
print(f'{t}: denied {e.response[\"Error\"][\"Code\"]}')
"acme: denied AccessDeniedException globex: denied AccessDeniedException [stdout] acme: denied AccessDeniedException globex: denied AccessDeniedException
Decode localstack session token structure
python3 -c "
import json, urllib.request, boto3
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
with urllib.request.urlopen(api+'?tenant=acme') as r: p=json.loads(r.read())
import base64
# decode session-token if it's structured (it's not in real AWS, but localstack format may differ)
print('session token starts with:', p['SessionToken'][:10])
# also try the IAM SimulatePrincipalPolicy
iam = boto3.client('iam')
"
# try decoding session token (LocalStack base64-encodes a JSON sometimes)
python3 -c "
import base64, json, urllib.request, boto3
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
with urllib.request.urlopen(api+'?tenant=acme') as r: p=json.loads(r.read())
tok = p['SessionToken']
for pad in ('','==','='):
try:
d = base64.b64decode(tok+pad)
print(d[:200])
break
except Exception:
pass
"session token starts with: FQoGZXIvYX
b"\x15\n\x06er/aws\x13\xcc\xa7\xbd\xb1q\x10\xbfO\x84\x8e\x8f\x00#W\x9a\x8fQp\xb3\xae\xfa\xa1\xecZ)\xfc\xaa|Q\x85\x9c\xfa\x94\x87\xdf]Ww\xa9\x1d\x86@\x03&w2\xf2[\xb3\xfb\xcff\xc1O\xabN\xe6\xad)\xbffi%:\xa0I\x96\x1a\xfb\xbb\xec1D\x8c\x17\xa4\xbe\xce\xbch\x0e2\xdd]\xaa\xbe\xb7\xa4\x91V\x98a\xb0\r\xf0\xb2\t\x8c\x94\xeaM\xfe\x96\xae\xbc\xd6\xa2\xec\xce/-&\x04\xd6\xe4?M)pI|\x19\xd2\xc5gw\xd8\xe1U\xc5\xbeh\x112'\x8e\x92L\x0b\xc4h~BTn7\xb7\x06\x8c-\x85\xa8Af(\xb0`yEs\x90-\xda\xdc7]6\xf8J\xc9yHdro{\xbcc\xb4\xc4_W\x11\x8e\xf2y\xcb\x18\xac\x0b"
[stdout]
session token starts with: FQoGZXIvYX
b"\x15\n\x06er/aws\x13\xcc\xa7\xbd\xb1q\x10\xbfO\x84\x8e\x8f\x00#W\x9a\x8fQp\xb3\xae\xfa\xa1\xecZ)\xfc\xaa|Q\x85\x9c\xfa\x94\x87\xdf]Ww\xa9\x1d\x86@\x03&w2\xf2[\xb3\xfb\xcff\xc1O\xabN\xe6\xad)\xbffi%:\xa0I\x96\x1a\xfb\xbb\xec1D\x8c\x17\xa4\xbe\xce\xbch\x0e2\xdd]\xaa\xbe\xb7\xa4\x91V\x98a\xb0\r\xf0\xb2\t\x8c\x94\xeaM\xfe\x96\xae\xbc\xd6\xa2\xec\xce/-&\x04\xd6\xe4?M)pI|\x19\xd2\xc5gw\xd8\xe1U\xc5\xbeh\x112'\x8e\x92L\x0b\xc4h~BTn7\xb7\x06\x8c-\x85\xa8Af(\xb0`yEs\x90-\xda\xdc7]6\xf8J\xc9yHdro{\xbcc\xb4\xc4_W\x11\x8e\xf2y\xcb\x18\xac\x0b"Probe matrix of operator/context-key combos against LocalStack
# try different operators / context keys to see what LocalStack honors for DDB LeadingKeys
for variant in 'forall_principal' 'forall_request' 'stringlike_principal' 'stringequalsifexists'; do
case "$variant" in
forall_principal) COND='"ForAllValues:StringEquals":{"dynamodb:LeadingKeys":["${aws:PrincipalTag/TenantID}"]}';;
forall_request) COND='"ForAllValues:StringEquals":{"dynamodb:LeadingKeys":["${aws:RequestTag/TenantID}"]}';;
stringlike_principal) COND='"ForAllValues:StringLike":{"dynamodb:LeadingKeys":["${aws:PrincipalTag/TenantID}"]}';;
stringequalsifexists) COND='"StringEqualsIfExists":{"dynamodb:LeadingKeys":"${aws:PrincipalTag/TenantID}"}';;
esac
cat > /tmp/p.json <<EOF
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"dynamodb:Query","Resource":"arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders","Condition":{$COND}}]}
EOF
aws iam put-role-policy --role-name TenantDataRole --policy-name TenantScopedDdb --policy-document file:///tmp/p.json
sleep 1
R=$(python3 -c "
import json, urllib.request, boto3
from botocore.exceptions import ClientError
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
with urllib.request.urlopen(api+'?tenant=acme') as r: p=json.loads(r.read())
ddb = boto3.client('dynamodb', aws_access_key_id=p['AccessKeyId'], aws_secret_access_key=p['SecretAccessKey'], aws_session_token=p['SessionToken'])
out = []
for t in ['acme','globex']:
try:
r=ddb.query(TableName='SaasOrders', KeyConditionExpression='TenantID = :t', ExpressionAttributeValues={':t':{'S':t}})
out.append(f'{t}=ALLOW({r[\"Count\"]})')
except ClientError as e:
out.append(f'{t}=DENY')
print(' | '.join(out))
")
printf '%-25s -> %s\n' "$variant" "$R"
doneforall_principal -> acme=ALLOW(2) | globex=ALLOW(2) forall_request -> acme=ALLOW(2) | globex=ALLOW(2) stringlike_principal -> acme=ALLOW(2) | globex=ALLOW(2) stringequalsifexists -> acme=ALLOW(2) | globex=ALLOW(2) [stdout] forall_principal -> acme=ALLOW(2) | globex=ALLOW(2) forall_request -> acme=ALLOW(2) | globex=ALLOW(2) stringlike_principal -> acme=ALLOW(2) | globex=ALLOW(2) stringequalsifexists -> acme=ALLOW(2) | globex=ALLOW(2)
Verify policy is attached to role
aws iam list-role-policies --role-name TenantDataRole aws iam get-role-policy --role-name TenantDataRole --policy-name TenantScopedDdb
{
"PolicyNames": [
"TenantScopedDdb"
]
}
{
"RoleName": "TenantDataRole",
"PolicyName": "TenantScopedDdb",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "dynamodb:Query",
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"StringEqualsIfExists": {
"dynamodb:LeadingKeys": "${aws:PrincipalTag/TenantID}"
}
}
}
]
}
}
[stdout]
{
"PolicyNames": [
"TenantScopedDdb"
]
}
{
"RoleName": "TenantDataRole",
"PolicyName": "TenantScopedDdb",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "dynamodb:Query",
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"StringEqualsIfExists": {
"dynamodb:LeadingKeys": "${aws:PrincipalTag/TenantID}"
}
}
}
]
}
}Try Deny statement using LeadingKeys
# Maybe LocalStack supports condition keys via specific spelling. try literal-only deny
cat > /tmp/p.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{"Effect":"Allow","Action":"dynamodb:Query","Resource":"arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders"},
{"Effect":"Deny","Action":"dynamodb:Query","Resource":"arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition":{"ForAllValues:StringNotEquals":{"dynamodb:LeadingKeys":["acme"]}}}
]
}
EOF
aws iam put-role-policy --role-name TenantDataRole --policy-name TenantScopedDdb --policy-document file:///tmp/p.json
sleep 1
python3 -c "
import json, urllib.request, boto3
from botocore.exceptions import ClientError
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
with urllib.request.urlopen(api+'?tenant=acme') as r: p=json.loads(r.read())
ddb = boto3.client('dynamodb', aws_access_key_id=p['AccessKeyId'], aws_secret_access_key=p['SecretAccessKey'], aws_session_token=p['SessionToken'])
for t in ['acme','globex']:
try:
r=ddb.query(TableName='SaasOrders', KeyConditionExpression='TenantID = :t', ExpressionAttributeValues={':t':{'S':t}})
print(f'{t}: count={r[\"Count\"]}')
except ClientError as e:
print(f'{t}: denied {e.response[\"Error\"][\"Code\"]}')
"acme: denied AccessDeniedException globex: denied AccessDeniedException [stdout] acme: denied AccessDeniedException globex: denied AccessDeniedException
Test PrincipalTag visibility to IAM evaluator
# Test if PrincipalTag is even visible to LocalStack's IAM evaluator
cat > /tmp/p.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{"Sid":"Allow","Effect":"Allow","Action":"dynamodb:Query","Resource":"arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders"},
{"Sid":"DenyIfPrincipalTagNotAcme","Effect":"Deny","Action":"dynamodb:Query","Resource":"arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition":{"StringNotEquals":{"aws:PrincipalTag/TenantID":"acme"}}}
]
}
EOF
aws iam put-role-policy --role-name TenantDataRole --policy-name TenantScopedDdb --policy-document file:///tmp/p.json
sleep 1
python3 -c "
import json, urllib.request, boto3
from botocore.exceptions import ClientError
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
for tenant in ['acme','globex']:
with urllib.request.urlopen(api+f'?tenant={tenant}') as r: p=json.loads(r.read())
ddb = boto3.client('dynamodb', aws_access_key_id=p['AccessKeyId'], aws_secret_access_key=p['SecretAccessKey'], aws_session_token=p['SessionToken'])
try:
r=ddb.query(TableName='SaasOrders', KeyConditionExpression='TenantID = :t', ExpressionAttributeValues={':t':{'S':tenant}})
print(f'creds={tenant} self-query: count={r[\"Count\"]}')
except ClientError as e:
print(f'creds={tenant} self-query: denied')
"creds=acme self-query: denied creds=globex self-query: denied [stdout] creds=acme self-query: denied creds=globex self-query: denied
Probe which context keys are populated
# Try variant substitutions/contexts to see if anything propagates
for v in 'PrincipalTag/TenantID' 'principaltag/TenantID' 'PrincipalTag:TenantID' 'username' 'userid'; do
cat > /tmp/p.json <<EOF
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"dynamodb:Query","Resource":"arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders"},
{"Sid":"DenyByCtx","Effect":"Deny","Action":"dynamodb:Query","Resource":"arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition":{"StringNotEqualsIfExists":{"aws:$v":"acme"}}}
]}
EOF
aws iam put-role-policy --role-name TenantDataRole --policy-name TenantScopedDdb --policy-document file:///tmp/p.json >/dev/null
sleep 1
R=$(python3 -c "
import json, urllib.request, boto3
from botocore.exceptions import ClientError
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
out=[]
for tenant in ['acme','globex']:
with urllib.request.urlopen(api+f'?tenant={tenant}') as r: p=json.loads(r.read())
ddb = boto3.client('dynamodb', aws_access_key_id=p['AccessKeyId'], aws_secret_access_key=p['SecretAccessKey'], aws_session_token=p['SessionToken'])
try:
r=ddb.query(TableName='SaasOrders', KeyConditionExpression='TenantID = :t', ExpressionAttributeValues={':t':{'S':tenant}})
out.append(f'{tenant}=ALLOW({r[\"Count\"]})')
except ClientError as e:
out.append(f'{tenant}=DENY')
print(' | '.join(out))
")
printf '%-30s -> %s\n' "$v" "$R"
donePrincipalTag/TenantID -> acme=DENY | globex=DENY principaltag/TenantID -> acme=DENY | globex=DENY PrincipalTag:TenantID -> acme=DENY | globex=DENY username -> acme=DENY | globex=DENY userid -> acme=DENY | globex=DENY [stdout] PrincipalTag/TenantID -> acme=DENY | globex=DENY principaltag/TenantID -> acme=DENY | globex=DENY PrincipalTag:TenantID -> acme=DENY | globex=DENY username -> acme=DENY | globex=DENY userid -> acme=DENY | globex=DENY
Check LocalStack config / fs access
# Hit localstack's internals - try to get config dump for path in /_localstack/health /_localstack/iam /_localstack/info /_localstack/configuration; do echo "== $path ==" curl -s "http://localstack:4566$path" 2>&1 | head -2 done echo "---" # Also try to read /etc files via filesystem if mounted ls -la /var/lib/localstack 2>&1 | head -5 ls -la /etc/localstack 2>&1 | head -5
== /_localstack/health ==
{"features": {"persistence": "disabled"}, "services": {"acm": "disabled", "apigateway": "running", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "running", "dynamodbstreams": "available", "ec2": "disabled", "es": "disabled", "events": "disabled", "firehose": "disabled", "iam": "running", "kinesis": "available", "kms": "running", "lambda": "running", "logs": "running", "opensearch": "disabled", "redshift": "disabled", "resource-groups": "disabled", "resourcegroupstaggingapi": "disabled", "route53": "disabled", "route53resolver": "disabled", "s3": "running", "s3control": "disabled", "scheduler": "disabled", "secretsmanager": "available", "ses": "disabled", "sns": "disabled", "sqs": "disabled", "ssm": "running", "stepfunctions": "disabled", "sts": "running", "support": "disabled", "swf": "disabled", "transcribe": "disabled", "account": "disabled", "acm-pca": "disabled", "amplify": "disabled", "apigatewaymanagementapi": "disabled", "apigatewayv2": "running", "appconfig": "disabled", "appconfigdata": "disabled", "application-autoscaling": "disabled", "appsync": "disabled", "athena": "disabled", "autoscaling": "disabled", "backup": "disabled", "batch": "disabled", "bedrock-runtime": "disabled", "bedrock": "disabled", "ce": "disabled", "cloudcontrol": "disabled", "cloudfront": "disabled", "cloudtrail": "disabled", "codeartifact": "disabled", "codebuild": "disabled", "codecommit": "disabled", "codeconnections": "disabled", "codedeploy": "disabled", "codepipeline": "disabled", "codestar-connections": "disabled", "cognito-identity": "disabled", "cognito-idp": "disabled", "dms": "disabled", "docdb": "disabled", "ecr": "disabled", "ecs": "disabled", "efs": "disabled", "eks-auth": "disabled", "eks": "disabled", "elasticache": "disabled", "elasticbeanstalk": "disabled", "elb": "disabled", "elbv2": "disabled", "emr-serverless": "disabled", "emr": "disabled", "fis": "disabled", "glacier": "disabled", "glue": "disabled", "identitystore": "disabled", "iot-data": "disabled", "iot": "disabled", "iotwireless": "disabled", "kafka": "disabled", "kinesisanalyticsv2": "disabled", "lakeformation": "disabled", "managedblockchain": "disabled", "mediaconvert": "disabled", "memorydb": "disabled", "mq": "disabled", "mwaa": "disabled", "neptune": "disabled", "organizations": "disabled", "pinpoint": "disabled", "pipes": "disabled", "ram": "disabled", "rds-data": "disabled", "rds": "disabled", "redshift-data": "disabled", "s3tables": "disabled", "sagemaker-runtime": "disabled", "sagemaker": "disabled", "serverlessrepo": "disabled", "servicediscovery": "disabled", "sesv2": "disabled", "shield": "disabled", "sso-admin": "disabled", "textract": "disabled", "timestream-query": "disabled", "timestream-write": "disabled", "transfer": "disabled", "verifiedpermissions": "disabled", "wafv2": "disabled", "xray": "disabled"}, "edition": "pro", "version": "2026.3.0"}== /_localstack/iam ==
== /_localstack/info ==
{"version": "2026.3.0:e28f8cfa4", "edition": "pro", "is_license_activated": true, "session_id": "1cdcd9d9-3560-4d34-8fc7-3082cebcdd60", "machine_id": "dkr_dc773c0a2fb6", "system": "Ubuntu 24.04.4 LTS,6.1.158,x86_64", "is_docker": true, "server_time_utc": "2026-04-29T17:49:44", "uptime": 685}== /_localstack/configuration ==
---
ls: cannot access '/var/lib/localstack': No such file or directory
ls: cannot access '/etc/localstack': No such file or directory
[stdout]
== /_localstack/health ==
{"features": {"persistence": "disabled"}, "services": {"acm": "disabled", "apigateway": "running", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "running", "dynamodbstreams": "available", "ec2": "disabled", "es": "disabled", "events": "disabled", "firehose": "disabled", "iam": "running", "kinesis": "available", "kms": "running", "lambda": "running", "logs": "running", "opensearch": "disabled", "redshift": "disabled", "resource-groups": "disabled", "resourcegroupstaggingapi": "disabled", "route53": "disabled", "route53resolver": "disabled", "s3": "running", "s3control": "disabled", "scheduler": "disabled", "secretsmanager": "available", "ses": "disabled", "sns": "disabled", "sqs": "disabled", "ssm": "running", "stepfunctions": "disabled", "sts": "running", "support": "disabled", "swf": "disabled", "transcribe": "disabled", "account": "disabled", "acm-pca": "disabled", "amplify": "disabled", "apigatewaymanagementapi": "disabled", "apigatewayv2": "running", "appconfig": "disabled", "appconfigdata": "disabled", "application-autoscaling": "disabled", "appsync": "disabled", "athena": "disabled", "autoscaling": "disabled", "backup": "disabled", "batch": "disabled", "bedrock-runtime": "disabled", "bedrock": "disabled", "ce": "disabled", "cloudcontrol": "disabled", "cloudfront": "disabled", "cloudtrail": "disabled", "codeartifact": "disabled", "codebuild": "disabled", "codecommit": "disabled", "codeconnections": "disabled", "codedeploy": "disabled", "codepipeline": "disabled", "codestar-connections": "disabled", "cognito-identity": "disabled", "cognito-idp": "disabled", "dms": "disabled", "docdb": "disabled", "ecr": "disabled", "ecs": "disabled", "efs": "disabled", "eks-auth": "disabled", "eks": "disabled", "elasticache": "disabled", "elasticbeanstalk": "disabled", "elb": "disabled", "elbv2": "disabled", "emr-serverless": "disabled", "emr": "disabled", "fis": "disabled", "glacier": "disabled", "glue": "disabled", "identitystore": "disabled", "iot-data": "disabled", "iot": "disabled", "iotwireless": "disabled", "kafka": "disabled", "kinesisanalyticsv2": "disabled", "lakeformation": "disabled", "managedblockchain": "disabled", "mediaconvert": "disabled", "memorydb": "disabled", "mq": "disabled", "mwaa": "disabled", "neptune": "disabled", "organizations": "disabled", "pinpoint": "disabled", "pipes": "disabled", "ram": "disabled", "rds-data": "disabled", "rds": "disabled", "redshift-data": "disabled", "s3tables": "disabled", "sagemaker-runtime": "disabled", "sagemaker": "disabled", "serverlessrepo": "disabled", "servicediscovery": "disabled", "sesv2": "disabled", "shield": "disabled", "sso-admin": "disabled", "textract": "disabled", "timestream-query": "disabled", "timestream-write": "disabled", "transfer": "disabled", "verifiedpermissions": "disabled", "wafv2": "disabled", "xray": "disabled"}, "edition": "pro", "version": "2026.3.0"}== /_localstack/iam ==
== /_localstack/info ==
{"version": "2026.3.0:e28f8cfa4", "edition": "pro", "is_license_activated": true, "session_id": "1cdcd9d9-3560-4d34-8fc7-3082cebcdd60", "machine_id": "dkr_dc773c0a2fb6", "system": "Ubuntu 24.04.4 LTS,6.1.158,x86_64", "is_docker": true, "server_time_utc": "2026-04-29T17:49:44", "uptime": 685}== /_localstack/configuration ==
---
ls: cannot access '/var/lib/localstack': No such file or directory
ls: cannot access '/etc/localstack': No such file or directory{
"description": "LocalStack DDB IAM enforcement docs",
"subagent_type": "general-purpose",
"prompt": "I need to know what condition keys LocalStack Pro 2026.3.0's IAM enforcement (ENFORCE_IAM=1) actually evaluates for DynamoDB at the data plane. Specifically:\n\n1. Does LocalStack Pro evaluate `dynamodb:LeadingKeys` in IAM policies for `dynamodb:Query` requests?\n2. Does it populate `aws:PrincipalTag/<key>` from STS AssumeRole session tags into the request context for downstream service calls?\n3. Are there extra config knobs needed beyond `ENFORCE_IAM=1` to enable fine-grained IAM (e.g. `IAM_SOFT_MODE`, `STS_LEGACY_BEHAVIOR`, anything else)?\n\nEmpirical observation in this environment:\n- IAM enforcement is on (CreateTable on undeclared tables denied, KMS list_keys denied with assumed-role creds whose policy doesn't allow it).\n- A policy that Allow's `dynamodb:Query` but denies via `ForAllValues:StringNotEquals dynamodb:LeadingKeys [\"acme\"]` denies BOTH same-tenant and cross-tenant queries → suggests `dynamodb:LeadingKeys` context key is never populated, so `ForAllValues:StringNotEquals` is vacuously true for empty set.\n- Same observation for `aws:PrincipalTag/TenantID` and `aws:username` , `StringNotEqualsIfExists` denies regardless of session, suggesting those keys aren't populated either.\n\nPlease search LocalStack Pro docs (docs.localstack.cloud), [code-host] issues, and changelog/release notes around late 2025 / early 2026 (versions 4.x → 2026.x) to confirm whether DynamoDB `LeadingKeys` IAM enforcement is supported, and whether session-tag-based ABAC works at all. Report findings concisely with sources."
}
, output,
{"type": "text", "text": "I now have a thorough picture. Let me consolidate findings.\n\n# Findings: LocalStack Pro IAM enforcement for DynamoDB (versions 4.x → 2026.x)\n\nYour empirical observations are consistent with LocalStack Pro's actual IAM coverage. Here is what the public docs and merged PRs confirm.\n\n## 1. `dynamodb:LeadingKeys` , NOT supported\n\nLocalStack's IAM Coverage page enumerates the supported condition keys and operators, and the list contains no DynamoDB service-specific keys:\n\n- Supported condition operators: `StringEquals`, `StringEqualsIgnoreCase`, `StringLike`, `ArnLike` / `ArnEquals`.\n- Supported condition keys (per coverage doc): `aws:SourceArn`, `aws:ResourceTag`, `aws:RequestTag`, `aws:PrincipalTag`.\n- The 4.4 release notes added a separate batch of keys: `iam:PermissionBoundary`, `aws:RequestTag`, `aws:TagKeys`, `sts:ExternalId`, `iam:AWSServiceName`, `aws:PrincipalIsAWSService`, `aws:PrincipalServiceName`, `aws:username`.\n- 4.10 added `aws:RequestedRegion` and `aws:PrincipalArn`.\n\nNone of the changelogs, IAM Coverage page, or 2026.03.0 release notes mention `dynamodb:LeadingKeys`, `dynamodb:Attributes`, `dynamodb:Select`, `dynamodb:FirstPartitionKeyValues`, or any other dynamodb-prefixed condition key. The IAM Coverage table only tests `DescribeTable`, `CreateTable`, and `DeleteTable` for DynamoDB , the data-plane operations (`Query`, `GetItem`, `PutItem`, `Scan`) are not listed as exercised under enforcement.\n\nThe IAM Coverage page also explicitly notes \"No support for policy variables\" for the Resource/NotResource elements, which is the same machinery that would expand `${aws:PrincipalTag/...}` inside `dynamodb:LeadingKeys` values.\n\nNet effect that matches your test: with no `dynamodb:LeadingKeys` ever populated into the request context, `ForAllValues:StringNotEquals` on it is vacuously true (empty set satisfies \"for all\"), so the Deny statement always fires regardless of the actual partition key the client sent. This is exactly what you observed.\n\n## 2. `aws:PrincipalTag/<key>` from STS session tags , partial / inconsistent for downstream\n\n- LocalStack PR #12508 (\"Support IAM transitive session tagging\", merged 2025-04-14) added storage of session tags and transitive tags in the STS session store and validation. The PR description explicitly says they \"can be used for IAM enforcement\", but the tests it adds are scoped to STS (`TestSTSAssumeRoleTagging`), not downstream-service authz.\n- The 4.4 blog post repeats: \"Tags marked as transitive on IAM sessions now propagate correctly and can be used for policy enforcement.\"\n- The IAM Coverage page lists `aws:PrincipalTag` as one of the four supported condition keys. So in principle a downstream call should see `aws:PrincipalTag/TenantID`.\n- But the Coverage page is also explicit that policy-variable substitution (`${aws:PrincipalTag/...}`) is not supported in Resource/NotResource, and there is no documentation that DynamoDB's data-plane authz pipeline pulls session tags into the request context. Combined with `StringNotEqualsIfExists` deny-by-default behavior you observed, the practical story is: session tags may or may not reach the per-service policy evaluator depending on the service, and DynamoDB data-plane is not on the exercised list.\n\nSo your observation that `StringNotEqualsIfExists` on `aws:PrincipalTag/TenantID` and `aws:username` denies regardless of session is consistent with the request context for DynamoDB data-plane calls not being populated with these keys , even though STS itself accepts and stores the tags.\n\n## 3. Config knobs\n\nOnly two knobs are documented:\n\n- `ENFORCE_IAM=1` (required): turns enforcement on. (Documented at the IAM Policy Enforcement page.)\n- `IAM_SOFT_MODE=1` (optional): logs would-be denials without blocking the call, used for discovery. Not relevant to your hard-deny behavior, but useful to confirm what the engine actually evaluated.\n- `DEBUG=1`: enables Explainable IAM log lines that show necessary / explicitly denied / implicitly denied permissions for each request , this is the fastest way to confirm whether `dynamodb:LeadingKeys` is even being looked at by the engine.\n\nThere is no documented `STS_LEGACY_BEHAVIOR` flag (the 2026.03.0 release migrated IAM/STS off Moto into LocalStack core, so any \"legacy\" path is gone in your version). No other knob is required to \"turn on\" fine-grained IAM , there is simply no fine-grained DynamoDB IAM to turn on.\n\n## Bottom line\n\n- `dynamodb:LeadingKeys`: not implemented in LocalStack Pro through 2026.03.0. Your `ForAllValues:StringNotEquals` deny-everything result is the expected (vacuous-truth) outcome of an unpopulated context key.\n- ABAC via session tags reaching DynamoDB data-plane: not supported in any documented form for DynamoDB. Session tags are stored by STS (PR #12508) and `aws:PrincipalTag` is listed as a globally supported key, but there is no evidence the DynamoDB provider injects principal tags into the auth-decision context, and policy-variable substitution is documented as unsupported.\n- No additional config knobs beyond `ENFORCE_IAM=1` (plus optional `IAM_SOFT_MODE=1` and `DEBUG=1`) will change this. The gap is feature coverage in the IAM engine / DynamoDB integration, not configuration.\n\nRecommendation if you need to test tenant isolation locally: enforce isolation at the application layer (separate tables, prefix-scoped resource ARNs) and test `dynamodb:LeadingKeys` policies against real AWS , or open a feature request on `localstack/localstack` referencing PR #12508 to extend session-tag-aware authorization into the DynamoDB provider and add the `dynamodb:` condition keys.\n\nSources:\n- [IAM Coverage (docs.localstack.cloud)](https://docs.localstack.cloud/aws/capabilities/security-testing/iam-coverage/)\n- [IAM Policy Enforcement (docs.localstack.cloud)](https://docs.localstack.cloud/aws/capabilities/security-testing/iam-policy-enforcement/)\n- [Explainable IAM (docs.localstack.cloud)](https://docs.localstack.cloud/user-guide/security-testing/explainable-iam/)\n- [LocalStack Changelog index](https://docs.localstack.cloud/aws/changelog/)\n- [LocalStack 4.4 release blog (condition keys + transitive session tagging)](https://blog.localstack.cloud/localstack-release-v-4-4-0/)\n- [LocalStack 4.10 release blog (`aws:RequestedRegion`, `aws:PrincipalArn`)](https://blog.localstack.cloud/localstack-for-aws-release-v-4-10-0/)\n- [LocalStack 2026.03.0 release blog (IAM/STS moved off Moto)](https://blog.localstack.cloud/localstack-for-aws-release-2026-03-0/)\n- [PR #12508 , Support IAM transitive session tagging (merged 2025-04-14)](https://[code-host]/localstack/localstack/pull/12508)\n- [Issue #4405 , DynamoDB IAM enforcement bug history](https://[code-host]/localstack/localstack/issues/4405)\n- [Issue #6329 , Lambda + DynamoDB IAM role enforcement gaps](https://[code-host]/localstack/localstack/issues/6329)"}
{"type": "text", "text": "agentId: a1979fbe72f9aef6f (use SendMessage with to: 'a1979fbe72f9aef6f' to continue this agent)\n<usage>total_tokens: 51375\ntool_uses: 23\nduration_ms: 299689</usage>"}
Final probe: does PrincipalTag literal-match work?
# Allow Query, but Deny only when PrincipalTag/TenantID == "globex" (literal, no substitution)
cat > /tmp/p.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{"Effect":"Allow","Action":"dynamodb:Query","Resource":"arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders"},
{"Sid":"DenyGlobex","Effect":"Deny","Action":"dynamodb:Query","Resource":"arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition":{"StringEquals":{"aws:PrincipalTag/TenantID":"globex"}}}
]
}
EOF
aws iam put-role-policy --role-name TenantDataRole --policy-name TenantScopedDdb --policy-document file:///tmp/p.json >/dev/null
sleep 1
python3 -c "
import json, urllib.request, boto3
from botocore.exceptions import ClientError
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
for tenant in ['acme','globex','globex-eu']:
with urllib.request.urlopen(api+f'?tenant={tenant}') as r: p=json.loads(r.read())
ddb = boto3.client('dynamodb', aws_access_key_id=p['AccessKeyId'], aws_secret_access_key=p['SecretAccessKey'], aws_session_token=p['SessionToken'])
try:
r=ddb.query(TableName='SaasOrders', KeyConditionExpression='TenantID = :t', ExpressionAttributeValues={':t':{'S':'acme'}})
print(f'creds={tenant} (tag should fire only for globex): ALLOW count={r[\"Count\"]}')
except ClientError as e:
print(f'creds={tenant}: DENY -> tag visible')
"creds=acme (tag should fire only for globex): ALLOW count=2 creds=globex: DENY -> tag visible creds=globex-eu (tag should fire only for globex): ALLOW count=2 [stdout] creds=acme (tag should fire only for globex): ALLOW count=2 creds=globex: DENY -> tag visible creds=globex-eu (tag should fire only for globex): ALLOW count=2
Test if Resource substitution is evaluated
# Allow only when the principal-tag equals the value baked into the request via aws:RequestTag
# but for DDB Query there's no RequestTag. Test policy-variable substitution against a literal request param.
# Try: deny when PrincipalTag != literal "globex" using substitution form
cat > /tmp/p.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{"Effect":"Allow","Action":"dynamodb:Query","Resource":"arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders"}
]
}
EOF
# Instead test substitution in Resource
cat > /tmp/p2.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{"Effect":"Allow","Action":"dynamodb:Query",
"Resource":"arn:aws:dynamodb:us-east-1:000000000000:table/${aws:PrincipalTag/TenantID}-orders"}
]
}
EOF
aws iam put-role-policy --role-name TenantDataRole --policy-name TenantScopedDdb --policy-document file:///tmp/p2.json >/dev/null
sleep 1
python3 -c "
import json, urllib.request, boto3
from botocore.exceptions import ClientError
api = boto3.client('ssm').get_parameter(Name='/harbor/saas/api-url')['Parameter']['Value']
with urllib.request.urlopen(api+'?tenant=acme') as r: p=json.loads(r.read())
ddb = boto3.client('dynamodb', aws_access_key_id=p['AccessKeyId'], aws_secret_access_key=p['SecretAccessKey'], aws_session_token=p['SessionToken'])
# resource doesn't match SaasOrders, so should deny
try:
r=ddb.query(TableName='SaasOrders', KeyConditionExpression='TenantID = :t', ExpressionAttributeValues={':t':{'S':'acme'}})
print(f'SaasOrders Query: ALLOWED (substitution NOT happening, resource matched somehow): count={r[\"Count\"]}')
except ClientError as e:
print(f'SaasOrders Query: DENIED (resource arn not allowed)')
"SaasOrders Query: DENIED (resource arn not allowed) [stdout] SaasOrders Query: DENIED (resource arn not allowed)
Restore canonical ABAC policy + dump role state
aws iam put-role-policy --role-name TenantDataRole --policy-name TenantScopedDdb --policy-document file:///app/policies/data-inline.json aws iam get-role-policy --role-name TenantDataRole --policy-name TenantScopedDdb --query PolicyDocument echo "---" aws iam get-role --role-name TenantDataRole --query Role.AssumeRolePolicyDocument echo "---" aws iam get-role-policy --role-name TenantTokenVendorRole --policy-name VendorScopedPolicy --query PolicyDocument
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TenantScopedItemAccess",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:BatchWriteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
],
"dynamodb:Attributes": [
"TenantID",
"OrderID",
"amount"
]
},
"StringEqualsIfExists": {
"dynamodb:Select": "SPECIFIC_ATTRIBUTES"
}
}
}
]
}
---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VendorAssumeWithTenantTag",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/TenantTokenVendorRole"
},
"Action": [
"sts:AssumeRole",
"sts:TagSession"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/TenantID": [
"acme",
"globex",
"globex-eu"
]
},
"Null": {
"aws:RequestTag/TenantID": "false"
},
"ForAllValues:StringEquals": {
"sts:TransitiveTagKeys": [
"TenantID"
]
}
}
}
]
}
---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AssumeTenantDataRoleOnly",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::000000000000:role/TenantDataRole"
},
{
"Sid": "ScopedLogWriteToOwnLogGroup",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/tenant-token-vendor:*"
},
{
"Sid": "ScopedLogGroupCreate",
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/tenant-token-vendor"
}
]
}
[stdout]
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TenantScopedItemAccess",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:BatchWriteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
],
"dynamodb:Attributes": [
"TenantID",
"OrderID",
"amount"
]
},
"StringEqualsIfExists": {
"dynamodb:Select": "SPECIFIC_ATTRIBUTES"
}
}
}
]
}
---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VendorAssumeWithTenantTag",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/TenantTokenVendorRole"
},
"Action": [
"sts:AssumeRole",
"sts:TagSession"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/TenantID": [
"acme",
"globex",
"globex-eu"
]
},
"Null": {
"aws:RequestTag/TenantID": "false"
},
"ForAllValues:StringEquals": {
"sts:TransitiveTagKeys": [
"TenantID"
]
}
}
}
]
}
---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AssumeTenantDataRoleOnly",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::000000000000:role/TenantDataRole"
},
{
"Sid": "ScopedLogWriteToOwnLogGroup",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/tenant-token-vendor:*"
},
{
"Sid": "ScopedLogGroupCreate",
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/tenant-token-vendor"
}
]
}Run final verifier
python3 verify.py 2>&1 || true
=== GET http://localstack:4566/_aws/execute-api/tqliivxlfz/prod/_user_request_/token?tenant=acme ===
status: 200 (creds present)
tenant tag: acme
expires: 2026-04-29T18:11:51.000004+00:00
=== same-tenant Query (acme) with safe attributes , must SUCCEED ===
got 2 items, first = {'OrderID': {'S': 'ord-1'}, 'TenantID': {'S': 'acme'}, 'amount': {'N': '104'}}
=== cross-tenant Query (acme creds, ask for globex) , must DENY ===
FAIL: cross-tenant Query returned 2 items
[stdout]
=== GET http://localstack:4566/_aws/execute-api/tqliivxlfz/prod/_user_request_/token?tenant=acme ===
status: 200 (creds present)
tenant tag: acme
expires: 2026-04-29T18:11:51.000004+00:00
=== same-tenant Query (acme) with safe attributes , must SUCCEED ===
got 2 items, first = {'OrderID': {'S': 'ord-1'}, 'TenantID': {'S': 'acme'}, 'amount': {'N': '104'}}
=== cross-tenant Query (acme creds, ask for globex) , must DENY ===
FAIL: cross-tenant Query returned 2 itemsNo file changes, the agent left the workspace untouched.
downloading uv 0.9.7 x86_64-unknown-linux-gnu
Downloading pygments (1.2MiB)
Downloading botocore (12.7MiB)
Downloading pygments
Downloading botocore
Installed 13 packages in 80ms
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.1, pluggy-1.6.0 -- /root/.cache/uv/archive-v0/OKrRhNQ5OzHkXGO9YFwtH/bin/python
cachedir: .pytest_cache
rootdir: /tests
plugins: json-ctrf-0.3.5
collecting ... collected 35 items
../tests/test_outputs.py::test_01_table_exists_with_pk_and_sk PASSED [ 2%]
../tests/test_outputs.py::test_02_seed_items_two_per_tenant PASSED [ 5%]
../tests/test_outputs.py::test_03_lambda_exists_python311 PASSED [ 8%]
../tests/test_outputs.py::test_04_data_and_vendor_roles_exist PASSED [ 11%]
../tests/test_outputs.py::test_05_apigw_route_token_exists_and_ssm_pointers_resolve PASSED [ 14%]
../tests/test_outputs.py::test_06_data_role_trust_includes_sts_tagsession PASSED [ 17%]
../tests/test_outputs.py::test_07_data_role_trust_principal_is_vendor_role_arn PASSED [ 20%]
../tests/test_outputs.py::test_08_data_role_trust_request_tag_allowlist PASSED [ 22%]
../tests/test_outputs.py::test_09_data_role_inline_uses_principal_tag_substitution_literal PASSED [ 25%]
../tests/test_outputs.py::test_10_data_role_inline_uses_forallvalues_stringequals PASSED [ 28%]
../tests/test_outputs.py::test_11_data_role_inline_resource_is_table_arn_not_wildcard PASSED [ 31%]
../tests/test_outputs.py::test_12_data_role_inline_actions_scoped_no_scan_no_wildcard PASSED [ 34%]
../tests/test_outputs.py::test_13_lambda_source_uses_transitive_tag_keys_and_tags PASSED [ 37%]
../tests/test_outputs.py::test_14_lambda_source_validates_tenant_input_regex PASSED [ 40%]
../tests/test_outputs.py::test_15_lambda_source_duration_seconds_le_900 PASSED [ 42%]
../tests/test_outputs.py::test_16_vendor_role_can_only_assume_data_role PASSED [ 45%]
../tests/test_outputs.py::test_17_no_admin_managed_policies_attached PASSED [ 48%]
../tests/test_outputs.py::test_18_e2e_get_token_for_acme_returns_creds_and_query_works PASSED [ 51%]
../tests/test_outputs.py::test_19_evaluator_admits_acme_blocks_globex_with_session_tag PASSED [ 54%]
../tests/test_outputs.py::test_20_invalid_tenant_input_rejected PASSED [ 57%]
../tests/test_outputs.py::test_21_third_tenant_globex_eu_seeded_two_items PASSED [ 60%]
../tests/test_outputs.py::test_22_data_role_trust_action_set_has_no_extras PASSED [ 62%]
../tests/test_outputs.py::test_23_data_role_trust_requires_tenantid_tag_present PASSED [ 65%]
../tests/test_outputs.py::test_24_data_role_inline_uses_dynamodb_attributes_column_scope PASSED [ 68%]
../tests/test_outputs.py::test_25_data_role_inline_does_not_use_leadingkey_singular_typo PASSED [ 71%]
../tests/test_outputs.py::test_26_vendor_role_has_no_aws_managed_basic_execution PASSED [ 74%]
../tests/test_outputs.py::test_27_vendor_role_inline_no_wildcard_actions PASSED [ 77%]
../tests/test_outputs.py::test_28_lambda_runtime_python311_memory_le_256 PASSED [ 80%]
../tests/test_outputs.py::test_29_lambda_source_transitive_tag_keys_is_tenantid_only PASSED [ 82%]
../tests/test_outputs.py::test_30_apigw_access_log_group_exists PASSED [ 85%]
../tests/test_outputs.py::test_31_lambda_permission_sourcearn_pinned_to_api PASSED [ 88%]
../tests/test_outputs.py::test_32_ddb_table_uses_customer_managed_cmk_sse PASSED [ 91%]
../tests/test_outputs.py::test_33_lambda_handler_does_not_log_credentials PASSED [ 94%]
../tests/test_outputs.py::test_34_doc_evaluator_blocks_tenant_substitution_attacks PASSED [ 97%]
../tests/test_outputs.py::test_35_globex_eu_token_works_e2e PASSED [100%]
=============================== warnings summary ===============================
test_outputs.py: 60 warnings
/root/.cache/uv/archive-v0/OKrRhNQ5OzHkXGO9YFwtH/lib/python3.12/site-packages/botocore/auth.py:424: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
datetime_now = datetime.datetime.utcnow()
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED ../tests/test_outputs.py::test_01_table_exists_with_pk_and_sk
PASSED ../tests/test_outputs.py::test_02_seed_items_two_per_tenant
PASSED ../tests/test_outputs.py::test_03_lambda_exists_python311
PASSED ../tests/test_outputs.py::test_04_data_and_vendor_roles_exist
PASSED ../tests/test_outputs.py::test_05_apigw_route_token_exists_and_ssm_pointers_resolve
PASSED ../tests/test_outputs.py::test_06_data_role_trust_includes_sts_tagsession
PASSED ../tests/test_outputs.py::test_07_data_role_trust_principal_is_vendor_role_arn
PASSED ../tests/test_outputs.py::test_08_data_role_trust_request_tag_allowlist
PASSED ../tests/test_outputs.py::test_09_data_role_inline_uses_principal_tag_substitution_literal
PASSED ../tests/test_outputs.py::test_10_data_role_inline_uses_forallvalues_stringequals
PASSED ../tests/test_outputs.py::test_11_data_role_inline_resource_is_table_arn_not_wildcard
PASSED ../tests/test_outputs.py::test_12_data_role_inline_actions_scoped_no_scan_no_wildcard
PASSED ../tests/test_outputs.py::test_13_lambda_source_uses_transitive_tag_keys_and_tags
PASSED ../tests/test_outputs.py::test_14_lambda_source_validates_tenant_input_regex
PASSED ../tests/test_outputs.py::test_15_lambda_source_duration_seconds_le_900
PASSED ../tests/test_outputs.py::test_16_vendor_role_can_only_assume_data_role
PASSED ../tests/test_outputs.py::test_17_no_admin_managed_policies_attached
PASSED ../tests/test_outputs.py::test_18_e2e_get_token_for_acme_returns_creds_and_query_works
PASSED ../tests/test_outputs.py::test_19_evaluator_admits_acme_blocks_globex_with_session_tag
PASSED ../tests/test_outputs.py::test_20_invalid_tenant_input_rejected
PASSED ../tests/test_outputs.py::test_21_third_tenant_globex_eu_seeded_two_items
PASSED ../tests/test_outputs.py::test_22_data_role_trust_action_set_has_no_extras
PASSED ../tests/test_outputs.py::test_23_data_role_trust_requires_tenantid_tag_present
PASSED ../tests/test_outputs.py::test_24_data_role_inline_uses_dynamodb_attributes_column_scope
PASSED ../tests/test_outputs.py::test_25_data_role_inline_does_not_use_leadingkey_singular_typo
PASSED ../tests/test_outputs.py::test_26_vendor_role_has_no_aws_managed_basic_execution
PASSED ../tests/test_outputs.py::test_27_vendor_role_inline_no_wildcard_actions
PASSED ../tests/test_outputs.py::test_28_lambda_runtime_python311_memory_le_256
PASSED ../tests/test_outputs.py::test_29_lambda_source_transitive_tag_keys_is_tenantid_only
PASSED ../tests/test_outputs.py::test_30_apigw_access_log_group_exists
PASSED ../tests/test_outputs.py::test_31_lambda_permission_sourcearn_pinned_to_api
PASSED ../tests/test_outputs.py::test_32_ddb_table_uses_customer_managed_cmk_sse
PASSED ../tests/test_outputs.py::test_33_lambda_handler_does_not_log_credentials
PASSED ../tests/test_outputs.py::test_34_doc_evaluator_blocks_tenant_substitution_attacks
PASSED ../tests/test_outputs.py::test_35_globex_eu_token_works_e2e
======================= 35 passed, 60 warnings in 6.81s ========================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_38755eb07d0042f7. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_38755eb07d0042f7 · verifier authoritative; classifier explanatory.