tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.List /app directory contents
ls /app && pwd
/app [stdout] /app
Check tools available
ls -la /app && which aws && aws --version
total 8 drwxr-xr-x 2 root root 4096 Apr 29 18:22 . drwxr-xr-x 1 root root 4096 Apr 29 18:22 .. /usr/local/bin/aws aws-cli/2.34.38 Python/3.14.4 Linux/6.1.158 exe/x86_64.ubuntu.24 [stdout] total 8 drwxr-xr-x 2 root root 4096 Apr 29 18:22 . drwxr-xr-x 1 root root 4096 Apr 29 18:22 .. /usr/local/bin/aws aws-cli/2.34.38 Python/3.14.4 Linux/6.1.158 exe/x86_64.ubuntu.24
Check AWS-related env vars
env | grep -iE "aws|localstack|endpoint" | head -30
AWS_DEFAULT_REGION=us-east-1 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test AWS_ACCESS_KEY_ID=test AWS_ENDPOINT_URL=http://localstack:4566 [stdout] AWS_DEFAULT_REGION=us-east-1 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test AWS_ACCESS_KEY_ID=test AWS_ENDPOINT_URL=http://localstack:4566
Verify connectivity to LocalStack
aws sts get-caller-identity
{
"UserId": "000000000000",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
[stdout]
{
"UserId": "000000000000",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}Check existing AWS resources
aws dynamodb list-tables && aws lambda list-functions --query 'Functions[].FunctionName' && aws iam list-roles --query 'Roles[].RoleName' --output text
{
"TableNames": []
}
[]
[stdout]
{
"TableNames": []
}
[]Create KMS CMK
aws kms create-key --description "saas orders cmk" --key-usage ENCRYPT_DECRYPT --query 'KeyMetadata.KeyId' --output text
1d403696-8cc0-4356-8987-2dd0cb08b417 [stdout] 1d403696-8cc0-4356-8987-2dd0cb08b417
Create KMS alias and confirm
KEY_ID=1d403696-8cc0-4356-8987-2dd0cb08b417 aws kms create-alias --alias-name alias/saas-orders-cmk --target-key-id $KEY_ID aws kms describe-key --key-id alias/saas-orders-cmk --query 'KeyMetadata.Arn' --output text
arn:aws:kms:us-east-1:000000000000:key/1d403696-8cc0-4356-8987-2dd0cb08b417 [stdout] arn:aws:kms:us-east-1:000000000000:key/1d403696-8cc0-4356-8987-2dd0cb08b417
Create DDB table with CMK encryption
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.TableArn' --output text
arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders [stdout] arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders
Enable PITR
aws dynamodb update-continuous-backups --table-name SaasOrders \ --point-in-time-recovery-specification PointInTimeRecoveryEnabled=true \ --query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus' --output text
ENABLED [stdout] ENABLED
{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
Seed 6 items across 3 tenants
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\": \"O-$i\"},
\"amount\": {\"N\": \"$((100 * i))\"},
\"password\": {\"S\": \"shh-$tenant-$i\"}
}"
done
done
aws dynamodb scan --table-name SaasOrders --select COUNT --query 'Count'6 [stdout] 6
Create build dir
mkdir -p /app/build && cd /app/build && pwd
/app/build [stdout] /app/build
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
Create vendor lambda exec role
aws iam create-role \ --role-name TenantTokenVendorRole \ --assume-role-policy-document file:///app/build/vendor-trust.json \ --query 'Role.Arn' --output text
arn:aws:iam::000000000000:role/TenantTokenVendorRole [stdout] arn:aws:iam::000000000000:role/TenantTokenVendorRole
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AssumeDataRoleOnly",
"Effect": "Allow",
"Action": [
"sts:AssumeRole",
"sts:TagSession"
],
"Resource": "arn:aws:iam::000000000000:role/TenantDataRole"
},
{
"Sid": "ScopedLogWriteOwnGroup",
"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"
}
]
}
Apply scoped inline policy to vendor role
aws iam put-role-policy \ --role-name TenantTokenVendorRole \ --policy-name VendorInline \ --policy-document file:///app/build/vendor-inline.json echo "vendor inline applied"
vendor inline applied [stdout] vendor inline applied
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VendorMayAssumeWithTenantTag",
"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"
}
}
}
]
}
Create TenantDataRole
aws iam create-role \ --role-name TenantDataRole \ --assume-role-policy-document file:///app/build/data-trust.json \ --query 'Role.Arn' --output text
arn:aws:iam::000000000000:role/TenantDataRole [stdout] arn:aws:iam::000000000000:role/TenantDataRole
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TenantScopedRowsAndAttributesAllow",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:BatchWriteItem",
"dynamodb:DescribeTable"
],
"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"
}
}
},
{
"Sid": "DenyPasswordColumnAlways",
"Effect": "Deny",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:Scan",
"dynamodb:PutItem",
"dynamodb:UpdateItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAnyValue:StringEquals": {
"dynamodb:Attributes": [
"password"
]
}
}
}
]
}
Apply data role inline policy
aws iam put-role-policy \ --role-name TenantDataRole \ --policy-name TenantDataInline \ --policy-document file:///app/build/data-inline.json echo "data inline applied"
data inline applied [stdout] data inline applied
import json
import os
import re
import boto3
_TENANT_RE = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
_DATA_ROLE_ARN = os.environ["DATA_ROLE_ARN"]
_DURATION = 900
_sts = boto3.client("sts")
def _bad(msg):
return {
"statusCode": 400,
"headers": {"content-type": "application/json"},
"body": json.dumps({"error": msg}),
}
def handler(event, _ctx):
qs = (event or {}).get("queryStringParameters") or {}
tenant = qs.get("tenant", "")
if not _TENANT_RE.match(tenant):
return _bad("invalid tenant")
resp = _sts.assume_role(
RoleArn=_DATA_ROLE_ARN,
RoleSessionName=f"tenant-{tenant}",
DurationSeconds=_DURATION,
Tags=[{"Key": "TenantID", "Value": tenant}],
TransitiveTagKeys=["TenantID"],
)
c = resp["Credentials"]
body = {
"AccessKeyId": c["AccessKeyId"],
"SecretAccessKey": c["SecretAccessKey"],
"SessionToken": c["SessionToken"],
"Expiration": c["Expiration"].isoformat(),
"TenantID": tenant,
}
return {
"statusCode": 200,
"headers": {"content-type": "application/json"},
"body": json.dumps(body),
}
Package lambda zip
zip -q tenant-token-vendor.zip lambda_function.py && ls -la tenant-token-vendor.zip
-rw-r--r-- 1 root root 736 Apr 29 18:26 tenant-token-vendor.zip [stdout] -rw-r--r-- 1 root root 736 Apr 29 18:26 tenant-token-vendor.zip
Deploy lambda
aws logs create-log-group --log-group-name /aws/lambda/tenant-token-vendor 2>&1 || true
aws lambda create-function \
--function-name tenant-token-vendor \
--runtime python3.11 \
--role arn:aws:iam::000000000000:role/TenantTokenVendorRole \
--handler lambda_function.handler \
--timeout 10 \
--environment "Variables={DATA_ROLE_ARN=arn:aws:iam::000000000000:role/TenantDataRole}" \
--zip-file fileb:///app/build/tenant-token-vendor.zip \
--query 'FunctionArn' --output textarn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor [stdout] arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor
Wait active + smoke invoke lambda
for i in 1 2 3 4 5 6 7 8 9 10; do
state=$(aws lambda get-function --function-name tenant-token-vendor --query 'Configuration.State' --output text 2>/dev/null)
if [ "$state" = "Active" ]; then echo "active"; break; fi
sleep 1
done
aws lambda invoke --function-name tenant-token-vendor \
--payload "$(echo -n '{"queryStringParameters":{"tenant":"acme"}}' | base64 -w 0)" /tmp/out.json
cat /tmp/out.json; echoactive
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
{"statusCode": 200, "headers": {"content-type": "application/json"}, "body": "{\"AccessKeyId\": \"LSIAQAAAAAAAEQI6J5F4\", \"SecretAccessKey\": \"747aS3A9jlNCxsNPWVYhmp+bfAR960c+v6RrAtNF\", \"SessionToken\": \"FQoGZXIvYXdzEOte8vGdiaitqZp/wP5SEVZrv5LGF4hmstbybMih8=ukRTjufD/Z+9dQMCKQf6TPL9=OoptWrK4x8Dd3tr1i9GvauNbPjKWibl+IE7Yecg0FU2j44X0BRU3IMn1DQGqyrlYNdSl5E+0QYUGNInBbYW5nQfji4KSoAjzcSs+Px48eBNGWlCftPo9uUTiTr5Q8Jd2H2/MTL+914DkmGESpQ0KK3dFELk8CYSrfXcFDz4cuDaInf9jG8zi+x6o05+PNldN/jfJswQDpNEKSWrjv5o6ocudK6bWs/UreHxuBCg2lYPiM5AZ+rQRSMGic88HCW8w6/otJyPXPzQYgMEMDJ/YA\", \"Expiration\": \"2026-04-29T18:41:44.908837+00:00\", \"TenantID\": \"acme\"}"}
[stdout]
active
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
{"statusCode": 200, "headers": {"content-type": "application/json"}, "body": "{\"AccessKeyId\": \"LSIAQAAAAAAAEQI6J5F4\", \"SecretAccessKey\": \"747aS3A9jlNCxsNPWVYhmp+bfAR960c+v6RrAtNF\", \"SessionToken\": \"FQoGZXIvYXdzEOte8vGdiaitqZp/wP5SEVZrv5LGF4hmstbybMih8=ukRTjufD/Z+9dQMCKQf6TPL9=OoptWrK4x8Dd3tr1i9GvauNbPjKWibl+IE7Yecg0FU2j44X0BRU3IMn1DQGqyrlYNdSl5E+0QYUGNInBbYW5nQfji4KSoAjzcSs+Px48eBNGWlCftPo9uUTiTr5Q8Jd2H2/MTL+914DkmGESpQ0KK3dFELk8CYSrfXcFDz4cuDaInf9jG8zi+x6o05+PNldN/jfJswQDpNEKSWrjv5o6ocudK6bWs/UreHxuBCg2lYPiM5AZ+rQRSMGic88HCW8w6/otJyPXPzQYgMEMDJ/YA\", \"Expiration\": \"2026-04-29T18:41:44.908837+00:00\", \"TenantID\": \"acme\"}"}Verify ABAC: own rows allow, foreign deny, password deny
CREDS=$(aws lambda invoke --function-name tenant-token-vendor \
--payload "$(echo -n '{"queryStringParameters":{"tenant":"acme"}}' | base64 -w 0)" /tmp/out.json >/dev/null && \
cat /tmp/out.json | python3 -c 'import json,sys;d=json.loads(json.load(sys.stdin)["body"]);print(d["AccessKeyId"],d["SecretAccessKey"],d["SessionToken"])')
read AK SK ST <<< "$CREDS"
echo "--- acme querying own rows w/ projection (should succeed) ---"
AWS_ACCESS_KEY_ID=$AK AWS_SECRET_ACCESS_KEY=$SK AWS_SESSION_TOKEN=$ST \
aws dynamodb query --table-name SaasOrders \
--key-condition-expression "TenantID = :t" \
--expression-attribute-values '{":t":{"S":"acme"}}' \
--projection-expression "TenantID,OrderID,amount" \
--select SPECIFIC_ATTRIBUTES \
--query 'Items'
echo "--- acme querying globex rows (should DENY) ---"
AWS_ACCESS_KEY_ID=$AK AWS_SECRET_ACCESS_KEY=$SK AWS_SESSION_TOKEN=$ST \
aws dynamodb query --table-name SaasOrders \
--key-condition-expression "TenantID = :t" \
--expression-attribute-values '{":t":{"S":"globex"}}' \
--projection-expression "TenantID,OrderID,amount" \
--select SPECIFIC_ATTRIBUTES 2>&1 | tail -3
echo "--- acme asking for password (should DENY) ---"
AWS_ACCESS_KEY_ID=$AK AWS_SECRET_ACCESS_KEY=$SK AWS_SESSION_TOKEN=$ST \
aws dynamodb query --table-name SaasOrders \
--key-condition-expression "TenantID = :t" \
--expression-attribute-values '{":t":{"S":"acme"}}' \
--projection-expression "TenantID,OrderID,#p" \
--expression-attribute-names '{"#p":"password"}' \
--select SPECIFIC_ATTRIBUTES 2>&1 | tail -3--- acme querying own rows w/ projection (should succeed) ---
[
{
"OrderID": {
"S": "O-1"
},
"TenantID": {
"S": "acme"
},
"amount": {
"N": "100"
}
},
{
"OrderID": {
"S": "O-2"
},
"TenantID": {
"S": "acme"
},
"amount": {
"N": "200"
}
}
]
--- acme querying globex rows (should DENY) ---
"ScannedCount": 2,
"ConsumedCapacity": null
}
--- acme asking for password (should DENY) ---
"ScannedCount": 2,
"ConsumedCapacity": null
}
[stdout]
--- acme querying own rows w/ projection (should succeed) ---
[
{
"OrderID": {
"S": "O-1"
},
"TenantID": {
"S": "acme"
},
"amount": {
"N": "100"
}
},
{
"OrderID": {
"S": "O-2"
},
"TenantID": {
"S": "acme"
},
"amount": {
"N": "200"
}
}
]
--- acme querying globex rows (should DENY) ---
"ScannedCount": 2,
"ConsumedCapacity": null
}
--- acme asking for password (should DENY) ---
"ScannedCount": 2,
"ConsumedCapacity": null
}Full output of denial cases
CREDS=$(aws lambda invoke --function-name tenant-token-vendor \
--payload "$(echo -n '{"queryStringParameters":{"tenant":"acme"}}' | base64 -w 0)" /tmp/out.json >/dev/null && \
cat /tmp/out.json | python3 -c 'import json,sys;d=json.loads(json.load(sys.stdin)["body"]);print(d["AccessKeyId"],d["SecretAccessKey"],d["SessionToken"])')
read AK SK ST <<< "$CREDS"
echo "=== acme -> globex (full output) ==="
AWS_ACCESS_KEY_ID=$AK AWS_SECRET_ACCESS_KEY=$SK AWS_SESSION_TOKEN=$ST \
aws dynamodb query --table-name SaasOrders \
--key-condition-expression "TenantID = :t" \
--expression-attribute-values '{":t":{"S":"globex"}}' \
--projection-expression "TenantID,OrderID,amount" \
--select SPECIFIC_ATTRIBUTES 2>&1
echo "=== acme -> password projection (full output) ==="
AWS_ACCESS_KEY_ID=$AK AWS_SECRET_ACCESS_KEY=$SK AWS_SESSION_TOKEN=$ST \
aws dynamodb query --table-name SaasOrders \
--key-condition-expression "TenantID = :t" \
--expression-attribute-values '{":t":{"S":"acme"}}' \
--projection-expression "TenantID,OrderID,#p" \
--expression-attribute-names '{"#p":"password"}' \
--select SPECIFIC_ATTRIBUTES 2>&1=== acme -> globex (full output) ===
{
"Items": [
{
"OrderID": {
"S": "O-1"
},
"TenantID": {
"S": "globex"
},
"amount": {
"N": "100"
}
},
{
"OrderID": {
"S": "O-2"
},
"TenantID": {
"S": "globex"
},
"amount": {
"N": "200"
}
}
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
=== acme -> password projection (full output) ===
{
"Items": [
{
"OrderID": {
"S": "O-1"
},
"TenantID": {
"S": "acme"
},
"password": {
"S": "shh-acme-1"
}
},
{
"OrderID": {
"S": "O-2"
},
"TenantID": {
"S": "acme"
},
"password": {
"S": "shh-acme-2"
}
}
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
[stdout]
=== acme -> globex (full output) ===
{
"Items": [
{
"OrderID": {
"S": "O-1"
},
"TenantID": {
"S": "globex"
},
"amount": {
"N": "100"
}
},
{
"OrderID": {
"S": "O-2"
},
"TenantID": {
"S": "globex"
},
"amount": {
"N": "200"
}
}
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
=== acme -> password projection (full output) ===
{
"Items": [
{
"OrderID": {
"S": "O-1"
},
"TenantID": {
"S": "acme"
},
"password": {
"S": "shh-acme-1"
}
},
{
"OrderID": {
"S": "O-2"
},
"TenantID": {
"S": "acme"
},
"password": {
"S": "shh-acme-2"
}
}
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}Inspect localstack config
curl -s http://localstack:4566/_localstack/health | head -c 500 echo curl -s http://localstack:4566/_localstack/info 2>/dev/null | head -c 500 echo curl -s http://localstack:4566/_localstack/config 2>/dev/null | head -c 1000
{"features": {"persistence": "disabled"}, "services": {"acm": "disabled", "apigateway": "available", "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", "r
{"version": "2026.3.0:e28f8cfa4", "edition": "pro", "is_license_activated": true, "session_id": "cc78144d-a5fa-45d7-84c6-26e70e794455", "machine_id": "dkr_dc773c0a2fb6", "system": "Ubuntu 24.04.4 LTS,6.1.158,x86_64", "is_docker": true, "server_time_utc": "2026-04-29T18:27:25", "uptime": 279}
[stdout]
{"features": {"persistence": "disabled"}, "services": {"acm": "disabled", "apigateway": "available", "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", "r
{"version": "2026.3.0:e28f8cfa4", "edition": "pro", "is_license_activated": true, "session_id": "cc78144d-a5fa-45d7-84c6-26e70e794455", "machine_id": "dkr_dc773c0a2fb6", "system": "Ubuntu 24.04.4 LTS,6.1.158,x86_64", "is_docker": true, "server_time_utc": "2026-04-29T18:27:25", "uptime": 279}Look for ENFORCE_IAM in localstack config
curl -s http://localstack:4566/_localstack/config 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps({k:v for k,v in d.items() if 'IAM' in k.upper() or 'ENFORCE' in k.upper()}, indent=2))"Exit code 1
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)
[error] tool reported failureVerify config endpoint
curl -sv http://localstack:4566/_localstack/config 2>&1 | tail -20
* 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/config 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 18:27:32 GMT < Content-Type: text/plain; charset=utf-8 < x-localstack: true < Content-Length: 0 < * Connection #0 to host localstack left intact [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/config 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 18:27:32 GMT < Content-Type: text/plain; charset=utf-8 < x-localstack: true < Content-Length: 0 < * Connection #0 to host localstack left intact
Try diagnose endpoint for IAM config
curl -s http://localstack:4566/_localstack/diagnose 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); cfg=d.get('config',{}); print(json.dumps({k:v for k,v in cfg.items() if 'IAM' in k.upper() or 'ENFORCE' in k.upper()}, indent=2))"Exit code 1
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)
[error] tool reported failureVerify diagnose endpoint
curl -sv http://localstack:4566/_localstack/diagnose 2>&1 | tail -30
* 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 18:27:38 GMT < Content-Type: text/plain; charset=utf-8 < x-localstack: true < Content-Length: 0 < * Connection #0 to host localstack left intact [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 18:27:38 GMT < Content-Type: text/plain; charset=utf-8 < x-localstack: true < Content-Length: 0 < * Connection #0 to host localstack left intact
Probe whether IAM enforcement runs at all
CREDS=$(aws lambda invoke --function-name tenant-token-vendor \
--payload "$(echo -n '{"queryStringParameters":{"tenant":"acme"}}' | base64 -w 0)" /tmp/out.json >/dev/null && \
cat /tmp/out.json | python3 -c 'import json,sys;d=json.loads(json.load(sys.stdin)["body"]);print(d["AccessKeyId"],d["SecretAccessKey"],d["SessionToken"])')
read AK SK ST <<< "$CREDS"
echo "=== try Scan (not in identity policy at all) ==="
AWS_ACCESS_KEY_ID=$AK AWS_SECRET_ACCESS_KEY=$SK AWS_SESSION_TOKEN=$ST \
aws dynamodb scan --table-name SaasOrders --select COUNT 2>&1 | head -10
echo "=== try ListTables (not in identity policy) ==="
AWS_ACCESS_KEY_ID=$AK AWS_SECRET_ACCESS_KEY=$SK AWS_SESSION_TOKEN=$ST \
aws dynamodb list-tables 2>&1 | head -10=== try Scan (not in identity policy at all) === aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Scan operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Scan on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Scan action === try ListTables (not in identity policy) === aws: [ERROR]: An error occurred (AccessDeniedException) when calling the ListTables operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:ListTables on resource: * because no identity-based policy allows the dynamodb:ListTables action [stdout] === try Scan (not in identity policy at all) === aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Scan operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Scan on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Scan action === try ListTables (not in identity policy) === aws: [ERROR]: An error occurred (AccessDeniedException) when calling the ListTables operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:ListTables on resource: * because no identity-based policy allows the dynamodb:ListTables action
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "OnlyAcmePartition",
"Effect": "Allow",
"Action": ["dynamodb:Query","dynamodb:GetItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["acme"]
}
}
}
]
}
Test hardcoded LeadingKeys check
aws iam put-role-policy --role-name TenantDataRole --policy-name TenantDataInline \
--policy-document file:///app/build/data-inline-test.json
sleep 1
CREDS=$(aws lambda invoke --function-name tenant-token-vendor \
--payload "$(echo -n '{"queryStringParameters":{"tenant":"acme"}}' | base64 -w 0)" /tmp/out.json >/dev/null && \
cat /tmp/out.json | python3 -c 'import json,sys;d=json.loads(json.load(sys.stdin)["body"]);print(d["AccessKeyId"],d["SecretAccessKey"],d["SessionToken"])')
read AK SK ST <<< "$CREDS"
echo "=== query acme partition (should work, hardcoded acme allowed) ==="
AWS_ACCESS_KEY_ID=$AK AWS_SECRET_ACCESS_KEY=$SK AWS_SESSION_TOKEN=$ST \
aws dynamodb query --table-name SaasOrders \
--key-condition-expression "TenantID = :t" \
--expression-attribute-values '{":t":{"S":"acme"}}' 2>&1 | tail -5
echo "=== query globex partition (should DENY, only acme allowed) ==="
AWS_ACCESS_KEY_ID=$AK AWS_SECRET_ACCESS_KEY=$SK AWS_SESSION_TOKEN=$ST \
aws dynamodb query --table-name SaasOrders \
--key-condition-expression "TenantID = :t" \
--expression-attribute-values '{":t":{"S":"globex"}}' 2>&1 | tail -5=== query acme partition (should work, hardcoded acme allowed) ===
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
=== query globex partition (should DENY, only acme allowed) ===
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
[stdout]
=== query acme partition (should work, hardcoded acme allowed) ===
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
=== query globex partition (should DENY, only acme allowed) ===
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}Restore real data inline + confirm shape
aws iam put-role-policy --role-name TenantDataRole --policy-name TenantDataInline \ --policy-document file:///app/build/data-inline.json echo "restored data inline" aws iam get-role-policy --role-name TenantDataRole --policy-name TenantDataInline \ --query 'PolicyDocument.Statement[0].Condition' --output json
restored data inline
{
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
],
"dynamodb:Attributes": [
"TenantID",
"OrderID",
"amount"
]
},
"StringEqualsIfExists": {
"dynamodb:Select": "SPECIFIC_ATTRIBUTES"
}
}
[stdout]
restored data inline
{
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
],
"dynamodb:Attributes": [
"TenantID",
"OrderID",
"amount"
]
},
"StringEqualsIfExists": {
"dynamodb:Select": "SPECIFIC_ATTRIBUTES"
}
}Create REST api, /token resource, GET method, AWS_PROXY integration
API_ID=$(aws apigateway create-rest-api --name harbor-saas-api --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_ID=$(aws apigateway create-resource --rest-api-id $API_ID --parent-id $ROOT_ID --path-part token --query 'id' --output text)
echo "token_id=$TOKEN_ID"
aws apigateway put-method --rest-api-id $API_ID --resource-id $TOKEN_ID --http-method GET --authorization-type NONE >/dev/null
LAMBDA_ARN=arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor
INTEG_URI="arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/${LAMBDA_ARN}/invocations"
aws apigateway put-integration --rest-api-id $API_ID --resource-id $TOKEN_ID \
--http-method GET --type AWS_PROXY --integration-http-method POST \
--uri "$INTEG_URI" >/dev/null
echo "$API_ID" > /tmp/api_id.txt
echo "wired"api_id=t8rhrajizu root_id=geejxxubiq token_id=yj42ma5n9t wired [stdout] api_id=t8rhrajizu root_id=geejxxubiq token_id=yj42ma5n9t wired
Add scoped invoke perm + deploy + access logs + redeploy
API_ID=$(cat /tmp/api_id.txt)
aws logs create-log-group --log-group-name /aws/apigw/harbor-saas-api 2>&1 || true
aws lambda add-permission \
--function-name tenant-token-vendor \
--statement-id apigw-harbor-saas \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:us-east-1:000000000000:${API_ID}/*/GET/token" \
--query 'Statement' --output text | head -c 200
echo
aws apigateway create-deployment --rest-api-id $API_ID --stage-name prod --query 'id' --output text
aws apigateway update-stage --rest-api-id $API_ID --stage-name prod \
--patch-operations \
"op=replace,path=/accessLogSettings/destinationArn,value=arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigw/harbor-saas-api" \
"op=replace,path=/accessLogSettings/format,value={\"requestId\":\"\$context.requestId\",\"ip\":\"\$context.identity.sourceIp\",\"path\":\"\$context.path\",\"status\":\"\$context.status\"}" \
--query 'accessLogSettings' --output json
aws apigateway create-deployment --rest-api-id $API_ID --stage-name prod --query 'id' --output text{"Sid": "apigw-harbor-saas", "Effect": "Allow", "Action": "lambda:InvokeFunction", "Resource": "arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor", "Principal": {"Service": "apigatewa
rfju6z282p
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","path":"$context.path","status":"$context.status"}
^
dr4mxu2vkc
[stdout]
{"Sid": "apigw-harbor-saas", "Effect": "Allow", "Action": "lambda:InvokeFunction", "Resource": "arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor", "Principal": {"Service": "apigatewa
rfju6z282p
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","path":"$context.path","status":"$context.status"}
^
dr4mxu2vkcSet access logs + redeploy
API_ID=$(cat /tmp/api_id.txt)
aws apigateway update-stage --rest-api-id $API_ID --stage-name prod \
--patch-operations \
"op=replace,path=/accessLogSettings/destinationArn,value=arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigw/harbor-saas-api" \
'op=replace,path=/accessLogSettings/format,value=$context.requestId $context.identity.sourceIp $context.path $context.status' \
--query 'accessLogSettings' --output json
aws apigateway create-deployment --rest-api-id $API_ID --stage-name prod --query 'id' --output text{
"format": "$context.requestId $context.identity.sourceIp $context.path $context.status",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigw/harbor-saas-api"
}
ipxgf7ei6h
[stdout]
{
"format": "$context.requestId $context.identity.sourceIp $context.path $context.status",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigw/harbor-saas-api"
}
ipxgf7ei6hEnd-to-end smoke through API Gateway
API_ID=$(cat /tmp/api_id.txt)
INVOKE_URL="http://localstack:4566/_aws/execute-api/${API_ID}/prod"
echo "invoke_url=$INVOKE_URL"
echo
echo "=== GET /token?tenant=acme ==="
curl -s "${INVOKE_URL}/token?tenant=acme" | python3 -m json.tool | head -20
echo
echo "=== GET /token?tenant=BAD (should 400) ==="
curl -s -o /tmp/bad.json -w "HTTP %{http_code}\n" "${INVOKE_URL}/token?tenant=BAD"
cat /tmp/bad.json
echo
echo "=== GET /token?tenant=globex-eu (hyphen allowed) ==="
curl -s "${INVOKE_URL}/token?tenant=globex-eu" | python3 -c 'import json,sys;d=json.load(sys.stdin);print("status:",d.get("statusCode"),"tenant:",json.loads(d.get("body","{}")).get("TenantID"))'invoke_url=http://localstack:4566/_aws/execute-api/t8rhrajizu/prod
=== GET /token?tenant=acme ===
{
"AccessKeyId": "LSIAQAAAAAAAFAAKPWXE",
"SecretAccessKey": "tDbt7hrox5G+OXAHLykGJd3ScjuW+hVKqHrTdbSU",
"SessionToken": "FQoGZXIvYXdzEqAs+6/XX5VUKoY1lMFrpIn8kzn5Uu1zaI0k3x0jaOa3IhJDEtl=dOhyoZmOCUvagQEoA1yHsyNYVmw+SoLIUtgOqsYCdMucW5wHqq4S+h+fWRIj=L2/4usE+wyfD1yy8pBv2qtBd8QzODEG3VU6sXxi+nI/csChnd7yo1rtToTxkTSAii33dluZ=2pVNFvcgswdCAWJK7TadvS04pHJ9r/Xvzo4G0cZINF9ae904d5rQyfognk5gV3ecMIrKpu1QXhmqyHJkFhkZYs+=qvvQXDhcJ5b4UBa4K1alxxsUicOfbbuY1VWO6SpvfqctFzxuMSE1=qQthIJclIbrvIvAyFu",
"Expiration": "2026-04-29T18:44:40.702985+00:00",
"TenantID": "acme"
}
=== GET /token?tenant=BAD (should 400) ===
HTTP 400
{"error": "invalid tenant"}
=== GET /token?tenant=globex-eu (hyphen allowed) ===
status: None tenant: None
[stdout]
invoke_url=http://localstack:4566/_aws/execute-api/t8rhrajizu/prod
=== GET /token?tenant=acme ===
{
"AccessKeyId": "LSIAQAAAAAAAFAAKPWXE",
"SecretAccessKey": "tDbt7hrox5G+OXAHLykGJd3ScjuW+hVKqHrTdbSU",
"SessionToken": "FQoGZXIvYXdzEqAs+6/XX5VUKoY1lMFrpIn8kzn5Uu1zaI0k3x0jaOa3IhJDEtl=dOhyoZmOCUvagQEoA1yHsyNYVmw+SoLIUtgOqsYCdMucW5wHqq4S+h+fWRIj=L2/4usE+wyfD1yy8pBv2qtBd8QzODEG3VU6sXxi+nI/csChnd7yo1rtToTxkTSAii33dluZ=2pVNFvcgswdCAWJK7TadvS04pHJ9r/Xvzo4G0cZINF9ae904d5rQyfognk5gV3ecMIrKpu1QXhmqyHJkFhkZYs+=qvvQXDhcJ5b4UBa4K1alxxsUicOfbbuY1VWO6SpvfqctFzxuMSE1=qQthIJclIbrvIvAyFu",
"Expiration": "2026-04-29T18:44:40.702985+00:00",
"TenantID": "acme"
}
=== GET /token?tenant=BAD (should 400) ===
HTTP 400
{"error": "invalid tenant"}
=== GET /token?tenant=globex-eu (hyphen allowed) ===
status: None tenant: NoneVerify globex-eu hyphen + a junk input
API_ID=$(cat /tmp/api_id.txt)
INVOKE_URL="http://localstack:4566/_aws/execute-api/${API_ID}/prod"
echo "=== GET /token?tenant=globex-eu (hyphen allowed) ==="
curl -s "${INVOKE_URL}/token?tenant=globex-eu" | python3 -c 'import json,sys;d=json.load(sys.stdin);print("AccessKeyId:",d.get("AccessKeyId","<missing>")[:12]+"...","TenantID:",d.get("TenantID"))'
echo
echo "=== GET /token?tenant=acme%20'OR'1=1 (injection-y, should 400) ==="
curl -s -w " HTTP %{http_code}\n" "${INVOKE_URL}/token?tenant=acme%20OR%201%3D1"=== GET /token?tenant=globex-eu (hyphen allowed) ===
AccessKeyId: LSIAQAAAAAAA... TenantID: globex-eu
=== GET /token?tenant=acme%20'OR'1=1 (injection-y, should 400) ===
{"error": "invalid tenant"} HTTP 400
[stdout]
=== GET /token?tenant=globex-eu (hyphen allowed) ===
AccessKeyId: LSIAQAAAAAAA... TenantID: globex-eu
=== GET /token?tenant=acme%20'OR'1=1 (injection-y, should 400) ===
{"error": "invalid tenant"} HTTP 400Write SSM pointers
API_ID=$(cat /tmp/api_id.txt)
INVOKE_URL="https://${API_ID}.execute-api.us-east-1.amazonaws.com/prod"
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[].[Name,Value]' --output table-------------------------------------------------------------------------------------------------------- | GetParametersByPath | +-------------------------------+----------------------------------------------------------------------+ | /harbor/saas/table-name | SaasOrders | | /harbor/saas/role-arn | arn:aws:iam::000000000000:role/TenantDataRole | | /harbor/saas/vendor-role-arn | arn:aws:iam::000000000000:role/TenantTokenVendorRole | | /harbor/saas/lambda-arn | arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor | | /harbor/saas/api-id | t8rhrajizu | | /harbor/saas/api-url | https://t8rhrajizu.execute-api.us-east-1.amazonaws.com/prod | +-------------------------------+----------------------------------------------------------------------+ [stdout] -------------------------------------------------------------------------------------------------------- | GetParametersByPath | +-------------------------------+----------------------------------------------------------------------+ | /harbor/saas/table-name | SaasOrders | | /harbor/saas/role-arn | arn:aws:iam::000000000000:role/TenantDataRole | | /harbor/saas/vendor-role-arn | arn:aws:iam::000000000000:role/TenantTokenVendorRole | | /harbor/saas/lambda-arn | arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor | | /harbor/saas/api-id | t8rhrajizu | | /harbor/saas/api-url | https://t8rhrajizu.execute-api.us-east-1.amazonaws.com/prod | +-------------------------------+----------------------------------------------------------------------+
Final end-to-end happy path
API_ID=$(aws ssm get-parameter --name /harbor/saas/api-id --query 'Parameter.Value' --output text)
INVOKE_URL="http://localstack:4566/_aws/execute-api/${API_ID}/prod"
echo "=== GET /token?tenant=acme ==="
RESP=$(curl -s "${INVOKE_URL}/token?tenant=acme")
echo "$RESP" | python3 -c 'import json,sys;d=json.loads(sys.stdin.read());print("got AccessKeyId:",d["AccessKeyId"][:8]+"...");print("expires:",d["Expiration"]);print("tenant:",d["TenantID"])'
read AK SK ST < <(echo "$RESP" | python3 -c 'import json,sys;d=json.loads(sys.stdin.read());print(d["AccessKeyId"],d["SecretAccessKey"],d["SessionToken"])')
echo
echo "=== ttl <= 900s sanity ==="
python3 -c "from datetime import datetime, timezone; exp=datetime.fromisoformat('$(echo "$RESP"|python3 -c "import json,sys;print(json.loads(sys.stdin.read())['Expiration'])")'); now=datetime.now(timezone.utc); print('ttl seconds:',int((exp-now).total_seconds()))"
echo
echo "=== call ddb as acme on own partition (with projection) ==="
AWS_ACCESS_KEY_ID=$AK AWS_SECRET_ACCESS_KEY=$SK AWS_SESSION_TOKEN=$ST \
aws dynamodb query --table-name SaasOrders \
--key-condition-expression "TenantID = :t" \
--expression-attribute-values '{":t":{"S":"acme"}}' \
--projection-expression "TenantID,OrderID,amount" \
--select SPECIFIC_ATTRIBUTES \
--query 'Items[].{tenant:TenantID.S,order:OrderID.S,amt:amount.N}' --output json=== GET /token?tenant=acme ===
got AccessKeyId: LSIAQAAA...
expires: 2026-04-29T18:45:30.815510+00:00
tenant: acme
=== ttl <= 900s sanity ===
ttl seconds: 899
=== call ddb as acme on own partition (with projection) ===
[
{
"tenant": "acme",
"order": "O-1",
"amt": "100"
},
{
"tenant": "acme",
"order": "O-2",
"amt": "200"
}
]
[stdout]
=== GET /token?tenant=acme ===
got AccessKeyId: LSIAQAAA...
expires: 2026-04-29T18:45:30.815510+00:00
tenant: acme
=== ttl <= 900s sanity ===
ttl seconds: 899
=== call ddb as acme on own partition (with projection) ===
[
{
"tenant": "acme",
"order": "O-1",
"amt": "100"
},
{
"tenant": "acme",
"order": "O-2",
"amt": "200"
}
]Final audit sweeps
echo "=== final lambda code log inspection (auditor scans CloudWatch) ===" aws logs filter-log-events --log-group-name /aws/lambda/tenant-token-vendor \ --filter-pattern "LSIA" --query 'events[].message' --output text | head -20 echo "(empty = no creds leaked)" echo echo "=== lambda invoke permission scope ===" aws lambda get-policy --function-name tenant-token-vendor --query 'Policy' --output text | python3 -m json.tool | head -25
=== final lambda code log inspection (auditor scans CloudWatch) ===
(empty = no creds leaked)
=== lambda invoke permission scope ===
{
"Version": "2012-10-17",
"Id": "default",
"Statement": [
{
"Sid": "apigw-harbor-saas",
"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:t8rhrajizu/*/GET/token"
}
}
}
]
}
[stdout]
=== final lambda code log inspection (auditor scans CloudWatch) ===
(empty = no creds leaked)
=== lambda invoke permission scope ===
{
"Version": "2012-10-17",
"Id": "default",
"Statement": [
{
"Sid": "apigw-harbor-saas",
"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:t8rhrajizu/*/GET/token"
}
}
}
]
}No file changes, the agent left the workspace untouched.
downloading uv 0.9.7 x86_64-unknown-linux-gnu
Downloading botocore (12.7MiB)
Downloading pygments (1.2MiB)
Downloading pygments
Downloading botocore
Installed 13 packages in 68ms
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.1, pluggy-1.6.0 -- /root/.cache/uv/archive-v0/9AB_zHd-FStPkmVQ3k3Fh/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 FAILED [ 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 FAILED [ 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%]
=================================== FAILURES ===================================
_________ test_12_data_role_inline_actions_scoped_no_scan_no_wildcard __________
def test_12_data_role_inline_actions_scoped_no_scan_no_wildcard():
"""Actions exclude Scan and wildcards."""
actions = set(_all_actions(_stmts(_inline_doc(ROLE_DATA))))
leaks = actions & FORBIDDEN_DDB_ACTIONS
> assert not leaks, f"{ROLE_DATA} inline grants forbidden actions: {leaks}"
E AssertionError: TenantDataRole inline grants forbidden actions: {'dynamodb:Scan'}
E assert not {'dynamodb:Scan'}
/tests/test_outputs.py:345: AssertionError
________________ test_15_lambda_source_duration_seconds_le_900 _________________
def test_15_lambda_source_duration_seconds_le_900():
"""Duration is at most 900s."""
src = _lambda_source()
matches = re.findall(r"DurationSeconds\s*[=:]\s*(\d+)", src)
> assert matches, "lambda source does not set DurationSeconds"
E AssertionError: lambda source does not set DurationSeconds
E assert []
/tests/test_outputs.py:388: AssertionError
=============================== warnings summary ===============================
test_outputs.py: 60 warnings
/root/.cache/uv/archive-v0/9AB_zHd-FStPkmVQ3k3Fh/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_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_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
FAILED ../tests/test_outputs.py::test_12_data_role_inline_actions_scoped_no_scan_no_wildcard
FAILED ../tests/test_outputs.py::test_15_lambda_source_duration_seconds_le_900
================== 2 failed, 33 passed, 60 warnings in 6.44s ===================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_6b44ab68a1c34bc9. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_6b44ab68a1c34bc9 · verifier authoritative; classifier explanatory.