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 /app directory and AWS configuration
ls -la /app/ 2>&1; echo "---"; aws configure list 2>&1; echo "---"; env | grep -iE "aws|local" | head -30
total 8 drwxr-xr-x 2 root root 4096 Apr 29 17:37 . drwxr-xr-x 1 root root 4096 Apr 29 17:38 .. --- NAME : VALUE : TYPE : LOCATION profile : <not set> : None : None access_key : ****************test : env : secret_key : ****************test : env : region : us-east-1 : env : ['AWS_REGION', 'AWS_DEFAULT_REGION'] --- AWS_DEFAULT_REGION=us-east-1 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test AWS_ACCESS_KEY_ID=test AWS_ENDPOINT_URL=http://localstack:4566 CLAUDE_CODE_EXECPATH=/root/.local/share/claude/versions/2.1.123 PATH=/root/.local/bin:/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin [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 .. --- NAME : VALUE : TYPE : LOCATION profile : <not set> : None : None access_key : ****************test : env : secret_key : ****************test : env : region : us-east-1 : env : ['AWS_REGION', 'AWS_DEFAULT_REGION'] --- AWS_DEFAULT_REGION=us-east-1 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test AWS_ACCESS_KEY_ID=test AWS_ENDPOINT_URL=http://localstack:4566 CLAUDE_CODE_EXECPATH=/root/.local/share/claude/versions/2.1.123 PATH=/root/.local/bin:/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Verify LocalStack is reachable and check identity
aws sts get-caller-identity 2>&1; echo "---"; curl -s http://localstack:4566/_localstack/health | head -100
{
"UserId": "000000000000",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
---
{"features": {"persistence": "disabled"}, "services": {"acm": "disabled", "apigateway": "available", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "available", "dynamodbstreams": "available", "ec2": "disabled", "es": "disabled", "events": "disabled", "firehose": "disabled", "iam": "available", "kinesis": "available", "kms": "available", "lambda": "available", "logs": "available", "opensearch": "disabled", "redshift": "disabled", "resource-groups": "disabled", "resourcegroupstaggingapi": "disabled", "route53": "disabled", "route53resolver": "disabled", "s3": "available", "s3control": "disabled", "scheduler": "disabled", "secretsmanager": "available", "ses": "disabled", "sns": "disabled", "sqs": "disabled", "ssm": "available", "stepfunctions": "disabled", "sts": "running", "support": "disabled", "swf": "disabled", "transcribe": "disabled", "account": "disabled", "acm-pca": "disabled", "amplify": "disabled", "apigatewaymanagementapi": "disabled", "apigatewayv2": "available", "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]
{
"UserId": "000000000000",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
---
{"features": {"persistence": "disabled"}, "services": {"acm": "disabled", "apigateway": "available", "cloudformation": "disabled", "cloudwatch": "disabled", "config": "disabled", "dynamodb": "available", "dynamodbstreams": "available", "ec2": "disabled", "es": "disabled", "events": "disabled", "firehose": "disabled", "iam": "available", "kinesis": "available", "kms": "available", "lambda": "available", "logs": "available", "opensearch": "disabled", "redshift": "disabled", "resource-groups": "disabled", "resourcegroupstaggingapi": "disabled", "route53": "disabled", "route53resolver": "disabled", "s3": "available", "s3control": "disabled", "scheduler": "disabled", "secretsmanager": "available", "ses": "disabled", "sns": "disabled", "sqs": "disabled", "ssm": "available", "stepfunctions": "disabled", "sts": "running", "support": "disabled", "swf": "disabled", "transcribe": "disabled", "account": "disabled", "acm-pca": "disabled", "amplify": "disabled", "apigatewaymanagementapi": "disabled", "apigatewayv2": "available", "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"}Check existing memory files
ls -la /logs/agent/sessions/projects/-app/memory/ 2>&1
total 8 drwxr-xr-x 2 root root 4096 Apr 29 17:39 . drwxr-xr-x 3 root root 4096 Apr 29 17:39 .. [stdout] total 8 drwxr-xr-x 2 root root 4096 Apr 29 17:39 . drwxr-xr-x 3 root root 4096 Apr 29 17:39 ..
Make working directories
mkdir -p /app/policies /app/lambda /app/scripts && ls /app
lambda policies scripts [stdout] lambda policies scripts
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VendorLambdaCanAssumeWithTenantTag",
"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": "TenantScopedReads",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query"
],
"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": "TenantScopedWrites",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
]
}
}
},
{
"Sid": "DenyPasswordAttributeAlways",
"Effect": "Deny",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAnyValue:StringEquals": {
"dynamodb:Attributes": [
"password"
]
}
}
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AssumeTenantDataRoleWithTags",
"Effect": "Allow",
"Action": [
"sts:AssumeRole",
"sts:TagSession"
],
"Resource": "arn:aws:iam::000000000000:role/TenantDataRole"
},
{
"Sid": "OwnLogGroupOnly",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/tenant-token-vendor",
"arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/tenant-token-vendor:log-stream:*"
]
},
{
"Sid": "OwnLogGroupCreate",
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/lambda/tenant-token-vendor"
}
]
}
import json
import os
import re
import logging
import boto3
from botocore.exceptions import ClientError
logger = logging.getLogger()
logger.setLevel(logging.INFO)
_TENANT_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
_DATA_ROLE_ARN = os.environ["DATA_ROLE_ARN"]
_MAX_DURATION_SECONDS = 900
_sts = boto3.client("sts")
def _bad_request(msg):
return {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": msg}),
}
def _server_error(msg):
return {
"statusCode": 500,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": msg}),
}
def handler(event, context):
qs = (event or {}).get("queryStringParameters") or {}
tenant = (qs.get("tenant") or "").strip()
if not tenant or not _TENANT_RE.match(tenant):
logger.info("rejecting invalid tenant id")
return _bad_request("invalid tenant id")
try:
resp = _sts.assume_role(
RoleArn=_DATA_ROLE_ARN,
RoleSessionName=f"tenant-{tenant}",
DurationSeconds=_MAX_DURATION_SECONDS,
Tags=[{"Key": "TenantID", "Value": tenant}],
TransitiveTagKeys=["TenantID"],
)
except ClientError as e:
logger.info("assume_role failed: %s", e.response.get("Error", {}).get("Code"))
return _server_error("assume failed")
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),
}
#!/usr/bin/env bash
set -euo pipefail
REGION="us-east-1"
ACCOUNT="000000000000"
TABLE="SaasOrders"
KEY_ALIAS="alias/saas-orders-cmk"
DATA_ROLE="TenantDataRole"
VENDOR_ROLE="TenantTokenVendorRole"
FN_NAME="tenant-token-vendor"
API_NAME="harbor-saas-api"
STAGE_NAME="prod"
LAMBDA_LOG_GROUP="/aws/lambda/${FN_NAME}"
APIGW_LOG_GROUP="/aws/apigateway/${API_NAME}/${STAGE_NAME}"
cd /app
# ----------------------------------------------------------------------------
# 1) KMS CMK + alias
# ----------------------------------------------------------------------------
echo "==> KMS key"
KEY_ID=$(aws kms describe-key --key-id "${KEY_ALIAS}" --query 'KeyMetadata.KeyId' --output text 2>/dev/null || true)
if [[ -z "${KEY_ID}" ]]; then
KEY_ID=$(aws kms create-key \
--description "saas-orders CMK" \
--key-usage ENCRYPT_DECRYPT \
--query 'KeyMetadata.KeyId' --output text)
aws kms create-alias --alias-name "${KEY_ALIAS}" --target-key-id "${KEY_ID}"
fi
KEY_ARN="arn:aws:kms:${REGION}:${ACCOUNT}:key/${KEY_ID}"
echo " key id: ${KEY_ID}"
# ----------------------------------------------------------------------------
# 2) DynamoDB table with CMK encryption
# ----------------------------------------------------------------------------
echo "==> DynamoDB table"
if ! aws dynamodb describe-table --table-name "${TABLE}" >/dev/null 2>&1; then
aws dynamodb create-table \
--table-name "${TABLE}" \
--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=${KEY_ID}" \
>/dev/null
aws dynamodb wait table-exists --table-name "${TABLE}"
fi
echo "==> enabling point-in-time-recovery"
aws dynamodb update-continuous-backups \
--table-name "${TABLE}" \
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true >/dev/null
# ----------------------------------------------------------------------------
# 3) Vendor lambda exec role (referenced as principal in data role trust)
# ----------------------------------------------------------------------------
echo "==> ${VENDOR_ROLE}"
if ! aws iam get-role --role-name "${VENDOR_ROLE}" >/dev/null 2>&1; then
aws iam create-role \
--role-name "${VENDOR_ROLE}" \
--assume-role-policy-document file:///app/policies/vendor-role-trust.json \
>/dev/null
fi
aws iam put-role-policy \
--role-name "${VENDOR_ROLE}" \
--policy-name "vendor-inline" \
--policy-document file:///app/policies/vendor-role-policy.json
# ----------------------------------------------------------------------------
# 4) Data role
# ----------------------------------------------------------------------------
echo "==> ${DATA_ROLE}"
if ! aws iam get-role --role-name "${DATA_ROLE}" >/dev/null 2>&1; then
aws iam create-role \
--role-name "${DATA_ROLE}" \
--assume-role-policy-document file:///app/policies/tenant-data-role-trust.json \
>/dev/null
else
aws iam update-assume-role-policy \
--role-name "${DATA_ROLE}" \
--policy-document file:///app/policies/tenant-data-role-trust.json
fi
aws iam put-role-policy \
--role-name "${DATA_ROLE}" \
--policy-name "tenant-data-inline" \
--policy-document file:///app/policies/tenant-data-role-policy.json
DATA_ROLE_ARN="arn:aws:iam::${ACCOUNT}:role/${DATA_ROLE}"
VENDOR_ROLE_ARN="arn:aws:iam::${ACCOUNT}:role/${VENDOR_ROLE}"
# ----------------------------------------------------------------------------
# 5) CloudWatch log groups (lambda + apigw access)
# ----------------------------------------------------------------------------
echo "==> log groups"
aws logs create-log-group --log-group-name "${LAMBDA_LOG_GROUP}" 2>/dev/null || true
aws logs create-log-group --log-group-name "${APIGW_LOG_GROUP}" 2>/dev/null || true
# ----------------------------------------------------------------------------
# 6) Package + deploy lambda
# ----------------------------------------------------------------------------
echo "==> packaging lambda"
PKG_DIR=$(mktemp -d)
cp /app/lambda/handler.py "${PKG_DIR}/handler.py"
( cd "${PKG_DIR}" && zip -q -r /tmp/handler.zip . )
echo "==> deploying lambda"
if aws lambda get-function --function-name "${FN_NAME}" >/dev/null 2>&1; then
aws lambda update-function-code \
--function-name "${FN_NAME}" \
--zip-file fileb:///tmp/handler.zip >/dev/null
aws lambda wait function-updated --function-name "${FN_NAME}"
aws lambda update-function-configuration \
--function-name "${FN_NAME}" \
--environment "Variables={DATA_ROLE_ARN=${DATA_ROLE_ARN}}" >/dev/null
aws lambda wait function-updated --function-name "${FN_NAME}"
else
aws lambda create-function \
--function-name "${FN_NAME}" \
--runtime python3.11 \
--role "${VENDOR_ROLE_ARN}" \
--handler handler.handler \
--zip-file fileb:///tmp/handler.zip \
--timeout 10 \
--environment "Variables={DATA_ROLE_ARN=${DATA_ROLE_ARN}}" >/dev/null
aws lambda wait function-active --function-name "${FN_NAME}"
fi
LAMBDA_ARN=$(aws lambda get-function --function-name "${FN_NAME}" \
--query 'Configuration.FunctionArn' --output text)
# ----------------------------------------------------------------------------
# 7) API Gateway REST API
# ----------------------------------------------------------------------------
echo "==> API Gateway"
API_ID=$(aws apigateway get-rest-apis \
--query "items[?name=='${API_NAME}'].id | [0]" --output text)
if [[ "${API_ID}" == "None" || -z "${API_ID}" ]]; then
API_ID=$(aws apigateway create-rest-api \
--name "${API_NAME}" \
--endpoint-configuration types=REGIONAL \
--query 'id' --output text)
fi
echo " api id: ${API_ID}"
ROOT_ID=$(aws apigateway get-resources --rest-api-id "${API_ID}" \
--query "items[?path=='/'].id | [0]" --output text)
TOKEN_ID=$(aws apigateway get-resources --rest-api-id "${API_ID}" \
--query "items[?path=='/token'].id | [0]" --output text)
if [[ "${TOKEN_ID}" == "None" || -z "${TOKEN_ID}" ]]; then
TOKEN_ID=$(aws apigateway create-resource \
--rest-api-id "${API_ID}" \
--parent-id "${ROOT_ID}" \
--path-part "token" \
--query 'id' --output text)
fi
# method + integration (idempotent)
aws apigateway put-method \
--rest-api-id "${API_ID}" \
--resource-id "${TOKEN_ID}" \
--http-method GET \
--authorization-type NONE 2>/dev/null || true
INTEGRATION_URI="arn:aws:apigateway:${REGION}: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 "${INTEGRATION_URI}" >/dev/null
# pin SourceArn to this API's specific resource ARN
SOURCE_ARN="arn:aws:execute-api:${REGION}:${ACCOUNT}:${API_ID}/*/GET/token"
aws lambda remove-permission \
--function-name "${FN_NAME}" \
--statement-id "apigw-invoke" 2>/dev/null || true
aws lambda add-permission \
--function-name "${FN_NAME}" \
--statement-id "apigw-invoke" \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "${SOURCE_ARN}" >/dev/null
# deploy stage AFTER wiring
echo "==> deploying stage"
aws apigateway create-deployment \
--rest-api-id "${API_ID}" \
--stage-name "${STAGE_NAME}" >/dev/null
# enable access logging on the stage
APIGW_LOG_ARN="arn:aws:logs:${REGION}:${ACCOUNT}:log-group:${APIGW_LOG_GROUP}"
aws apigateway update-stage \
--rest-api-id "${API_ID}" \
--stage-name "${STAGE_NAME}" \
--patch-operations \
"op=replace,path=/accessLogSettings/destinationArn,value=${APIGW_LOG_ARN}" \
'op=replace,path=/accessLogSettings/format,value={"requestId":"$context.requestId","ip":"$context.identity.sourceIp","method":"$context.httpMethod","path":"$context.path","status":"$context.status"}' \
>/dev/null
# redeploy after stage change (apigw config does not promote until you do)
aws apigateway create-deployment \
--rest-api-id "${API_ID}" \
--stage-name "${STAGE_NAME}" >/dev/null
INVOKE_URL="http://localhost:4566/restapis/${API_ID}/${STAGE_NAME}/_user_request_/token"
# ----------------------------------------------------------------------------
# 8) SSM pointers
# ----------------------------------------------------------------------------
echo "==> SSM parameters"
aws ssm put-parameter --name /harbor/saas/table-name --type String --overwrite --value "${TABLE}" >/dev/null
aws ssm put-parameter --name /harbor/saas/role-arn --type String --overwrite --value "${DATA_ROLE_ARN}" >/dev/null
aws ssm put-parameter --name /harbor/saas/vendor-role-arn --type String --overwrite --value "${VENDOR_ROLE_ARN}" >/dev/null
aws ssm put-parameter --name /harbor/saas/lambda-arn --type String --overwrite --value "${LAMBDA_ARN}" >/dev/null
aws ssm put-parameter --name /harbor/saas/api-id --type String --overwrite --value "${API_ID}" >/dev/null
aws ssm put-parameter --name /harbor/saas/api-url --type String --overwrite --value "${INVOKE_URL}" >/dev/null
echo
echo "DATA_ROLE_ARN = ${DATA_ROLE_ARN}"
echo "VENDOR_ROLE_ARN = ${VENDOR_ROLE_ARN}"
echo "LAMBDA_ARN = ${LAMBDA_ARN}"
echo "API_ID = ${API_ID}"
echo "INVOKE_URL = ${INVOKE_URL}"
Provision all AWS resources
chmod +x /app/scripts/apply.sh && bash /app/scripts/apply.sh 2>&1 | tail -80
==> KMS key
key id: 2b7134b1-1cab-40dc-8cb9-285700ce9e49
==> DynamoDB table
==> enabling point-in-time-recovery
==> TenantTokenVendorRole
==> TenantDataRole
==> log groups
==> packaging lambda
==> deploying lambda
==> API Gateway
api id: 4ftewofzbt
{
"httpMethod": "GET",
"authorizationType": "NONE",
"apiKeyRequired": false
}
==> deploying stage
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","method":"$context.httpMethod","path":"$context.path","status":"$context.status"}
^
[stdout]
==> KMS key
key id: 2b7134b1-1cab-40dc-8cb9-285700ce9e49
==> DynamoDB table
==> enabling point-in-time-recovery
==> TenantTokenVendorRole
==> TenantDataRole
==> log groups
==> packaging lambda
==> deploying lambda
==> API Gateway
api id: 4ftewofzbt
{
"httpMethod": "GET",
"authorizationType": "NONE",
"apiKeyRequired": false
}
==> deploying stage
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","method":"$context.httpMethod","path":"$context.path","status":"$context.status"}
^[
{
"op": "replace",
"path": "/accessLogSettings/destinationArn",
"value": "__APIGW_LOG_ARN__"
},
{
"op": "replace",
"path": "/accessLogSettings/format",
"value": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\"}"
}
]
/app/scripts/apply.sh
# enable access logging on the stage
APIGW_LOG_ARN="arn:aws:logs:${REGION}:${ACCOUNT}:log-group:${APIGW_LOG_GROUP}"
aws apigateway update-stage \
--rest-api-id "${API_ID}" \
--stage-name "${STAGE_NAME}" \
--patch-operations \
"op=replace,path=/accessLogSettings/destinationArn,value=${APIGW_LOG_ARN}" \
'op=replace,path=/accessLogSettings/format,value={"requestId":"$context.requestId","ip":"$context.identity.sourceIp","method":"$context.httpMethod","path":"$context.path","status":"$context.status"}' \
>/dev/null# enable access logging on the stage
APIGW_LOG_ARN="arn:aws:logs:${REGION}:${ACCOUNT}:log-group:${APIGW_LOG_GROUP}"
PATCH_FILE=$(mktemp)
sed "s|__APIGW_LOG_ARN__|${APIGW_LOG_ARN}|" /app/policies/stage-access-log-patch.json > "${PATCH_FILE}"
aws apigateway update-stage \
--rest-api-id "${API_ID}" \
--stage-name "${STAGE_NAME}" \
--patch-operations "file://${PATCH_FILE}" \
>/dev/nullRe-run provisioning with stage patch fix
bash /app/scripts/apply.sh 2>&1 | tail -40
==> KMS key
key id: 2b7134b1-1cab-40dc-8cb9-285700ce9e49
==> DynamoDB table
==> enabling point-in-time-recovery
==> TenantTokenVendorRole
==> TenantDataRole
==> log groups
==> packaging lambda
==> deploying lambda
==> API Gateway
api id: 4ftewofzbt
{
"httpMethod": "GET",
"authorizationType": "NONE",
"apiKeyRequired": false
}
==> deploying stage
==> SSM parameters
DATA_ROLE_ARN = arn:aws:iam::000000000000:role/TenantDataRole
VENDOR_ROLE_ARN = arn:aws:iam::000000000000:role/TenantTokenVendorRole
LAMBDA_ARN = arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor
API_ID = 4ftewofzbt
INVOKE_URL = http://localhost:4566/restapis/4ftewofzbt/prod/_user_request_/token
[stdout]
==> KMS key
key id: 2b7134b1-1cab-40dc-8cb9-285700ce9e49
==> DynamoDB table
==> enabling point-in-time-recovery
==> TenantTokenVendorRole
==> TenantDataRole
==> log groups
==> packaging lambda
==> deploying lambda
==> API Gateway
api id: 4ftewofzbt
{
"httpMethod": "GET",
"authorizationType": "NONE",
"apiKeyRequired": false
}
==> deploying stage
==> SSM parameters
DATA_ROLE_ARN = arn:aws:iam::000000000000:role/TenantDataRole
VENDOR_ROLE_ARN = arn:aws:iam::000000000000:role/TenantTokenVendorRole
LAMBDA_ARN = arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor
API_ID = 4ftewofzbt
INVOKE_URL = http://localhost:4566/restapis/4ftewofzbt/prod/_user_request_/token#!/usr/bin/env bash
set -euo pipefail
put() {
local tenant="$1" order="$2" amount="$3" pw="$4"
aws dynamodb put-item --table-name SaasOrders --item "$(cat <<EOF
{
"TenantID": {"S": "${tenant}"},
"OrderID": {"S": "${order}"},
"amount": {"N": "${amount}"},
"password": {"S": "${pw}"}
}
EOF
)" >/dev/null
}
put acme o-001 1200 acme-secret-1
put acme o-002 3400 acme-secret-2
put globex o-101 555 globex-secret-1
put globex o-102 9000 globex-secret-2
put globex-eu o-201 120 globex-eu-secret-1
put globex-eu o-202 4500 globex-eu-secret-2
echo "seeded:"
aws dynamodb scan --table-name SaasOrders \
--projection-expression "TenantID, OrderID, amount" \
--query 'Items[].[TenantID.S, OrderID.S, amount.N]' --output table
Seed DynamoDB rows
chmod +x /app/scripts/seed.sh && bash /app/scripts/seed.sh 2>&1 | tail -30
seeded: -------------------------------- | Scan | +-----------+---------+--------+ | globex | o-101 | 555 | | globex | o-102 | 9000 | | globex-eu| o-201 | 120 | | globex-eu| o-202 | 4500 | | acme | o-001 | 1200 | | acme | o-002 | 3400 | +-----------+---------+--------+ [stdout] seeded: -------------------------------- | Scan | +-----------+---------+--------+ | globex | o-101 | 555 | | globex | o-102 | 9000 | | globex-eu| o-201 | 120 | | globex-eu| o-202 | 4500 | | acme | o-001 | 1200 | | acme | o-002 | 3400 | +-----------+---------+--------+
#!/usr/bin/env bash
set -euo pipefail
API_URL=$(aws ssm get-parameter --name /harbor/saas/api-url --query 'Parameter.Value' --output text)
echo "API URL = ${API_URL}"
echo
echo "=== 1) bad tenant id (should be HTTP 400)"
curl -sS -o /tmp/badbody -w "status=%{http_code}\n" "${API_URL}?tenant=ACME!" || true
cat /tmp/badbody; echo
echo
echo "=== 2) good tenant id (acme) -> temp creds"
HTTP_CODE=$(curl -sS -o /tmp/tokenbody -w "%{http_code}" "${API_URL}?tenant=acme")
echo "status=${HTTP_CODE}"
python3 -c "import json,sys; d=json.load(open('/tmp/tokenbody')); print('keys:', sorted(d))"
AK=$(python3 -c "import json; print(json.load(open('/tmp/tokenbody'))['AccessKeyId'])")
SK=$(python3 -c "import json; print(json.load(open('/tmp/tokenbody'))['SecretAccessKey'])")
ST=$(python3 -c "import json; print(json.load(open('/tmp/tokenbody'))['SessionToken'])")
EXP=$(python3 -c "import json; print(json.load(open('/tmp/tokenbody'))['Expiration'])")
echo "expiration: ${EXP}"
echo
echo "=== 3) acme creds query own rows (projection: TenantID,OrderID,amount) -> expect SUCCESS"
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" \
--query 'Items[].[TenantID.S,OrderID.S,amount.N]' --output table
echo
echo "=== 4) acme creds query GLOBEX (cross-tenant) -> expect AccessDenied"
set +e
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" 2>&1 | tail -3
RC=$?
set -e
echo "(rc=${RC}; non-zero with AccessDenied is correct)"
echo
echo "=== 5) acme creds try to read 'password' attribute -> expect AccessDenied"
set +e
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"}' 2>&1 | tail -3
RC=$?
set -e
echo "(rc=${RC}; non-zero with AccessDenied is correct)"
echo
echo "=== 6) acme creds try Scan (not in policy action set) -> expect AccessDenied"
set +e
AWS_ACCESS_KEY_ID="${AK}" AWS_SECRET_ACCESS_KEY="${SK}" AWS_SESSION_TOKEN="${ST}" \
aws dynamodb scan --table-name SaasOrders 2>&1 | tail -3
RC=$?
set -e
echo "(rc=${RC}; non-zero with AccessDenied is correct)"
echo
echo "=== 7) credential body must NOT have appeared in lambda CloudWatch logs"
LOG_GROUP="/aws/lambda/tenant-token-vendor"
STREAMS=$(aws logs describe-log-streams --log-group-name "${LOG_GROUP}" --query 'logStreams[].logStreamName' --output text 2>/dev/null || true)
LEAK=0
for s in ${STREAMS}; do
EVENTS=$(aws logs get-log-events --log-group-name "${LOG_GROUP}" --log-stream-name "${s}" --query 'events[].message' --output text 2>/dev/null || true)
if grep -F "${AK}" <<<"${EVENTS}" >/dev/null; then echo "LEAK: AccessKeyId in stream ${s}"; LEAK=1; fi
if grep -F "${SK}" <<<"${EVENTS}" >/dev/null; then echo "LEAK: SecretAccessKey in stream ${s}"; LEAK=1; fi
if grep -F "${ST}" <<<"${EVENTS}" >/dev/null; then echo "LEAK: SessionToken in stream ${s}"; LEAK=1; fi
done
if [[ ${LEAK} -eq 0 ]]; then echo "no credential leakage in CloudWatch logs"; fi
Run end-to-end smoke test
chmod +x /app/scripts/smoketest.sh && bash /app/scripts/smoketest.sh 2>&1
Exit code 1 API URL = http://localhost:4566/restapis/4ftewofzbt/prod/_user_request_/token === 1) bad tenant id (should be HTTP 400) curl: (7) Failed to connect to localhost port 4566 after 0 ms: Couldn't connect to server status=000 cat: /tmp/badbody: No such file or directory [error] tool reported failure
/app/scripts/apply.sh
INVOKE_URL="http://localhost:4566/restapis/${API_ID}/${STAGE_NAME}/_user_request_/token"INVOKE_URL="http://localstack:4566/restapis/${API_ID}/${STAGE_NAME}/_user_request_/token"Repoint api-url SSM and rerun smoke test
aws ssm put-parameter --name /harbor/saas/api-url --type String --overwrite --value "http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token" >/dev/null && bash /app/scripts/smoketest.sh 2>&1
API URL = http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token
=== 1) bad tenant id (should be HTTP 400)
status=400
{"error": "invalid tenant id"}
=== 2) good tenant id (acme) -> temp creds
status=200
keys: ['AccessKeyId', 'Expiration', 'SecretAccessKey', 'SessionToken', 'TenantID']
expiration: 2026-04-29T18:00:38.703420+00:00
=== 3) acme creds query own rows (projection: TenantID,OrderID,amount) -> expect SUCCESS
---------------------------
| Query |
+------+---------+--------+
| acme| o-001 | 1200 |
| acme| o-002 | 3400 |
+------+---------+--------+
=== 4) acme creds query GLOBEX (cross-tenant) -> expect AccessDenied
"ScannedCount": 2,
"ConsumedCapacity": null
}
(rc=0; non-zero with AccessDenied is correct)
=== 5) acme creds try to read 'password' attribute -> expect AccessDenied
"ScannedCount": 2,
"ConsumedCapacity": null
}
(rc=0; non-zero with AccessDenied is correct)
=== 6) acme creds try Scan (not in policy action set) -> expect AccessDenied
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
(rc=254; non-zero with AccessDenied is correct)
=== 7) credential body must NOT have appeared in lambda CloudWatch logs
no credential leakage in CloudWatch logs
[stdout]
API URL = http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token
=== 1) bad tenant id (should be HTTP 400)
status=400
{"error": "invalid tenant id"}
=== 2) good tenant id (acme) -> temp creds
status=200
keys: ['AccessKeyId', 'Expiration', 'SecretAccessKey', 'SessionToken', 'TenantID']
expiration: 2026-04-29T18:00:38.703420+00:00
=== 3) acme creds query own rows (projection: TenantID,OrderID,amount) -> expect SUCCESS
---------------------------
| Query |
+------+---------+--------+
| acme| o-001 | 1200 |
| acme| o-002 | 3400 |
+------+---------+--------+
=== 4) acme creds query GLOBEX (cross-tenant) -> expect AccessDenied
"ScannedCount": 2,
"ConsumedCapacity": null
}
(rc=0; non-zero with AccessDenied is correct)
=== 5) acme creds try to read 'password' attribute -> expect AccessDenied
"ScannedCount": 2,
"ConsumedCapacity": null
}
(rc=0; non-zero with AccessDenied is correct)
=== 6) acme creds try Scan (not in policy action set) -> expect AccessDenied
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
(rc=254; non-zero with AccessDenied is correct)
=== 7) credential body must NOT have appeared in lambda CloudWatch logs
no credential leakage in CloudWatch logsInspect LocalStack config for IAM enforcement
curl -s http://localstack:4566/_localstack/info 2>&1 | head -50; echo "---"; docker ps 2>&1 | head -5; echo "---env---"; curl -s http://localstack:4566/_localstack/diagnose 2>&1 | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps({k:v for k,v in d.get('config',{}).items() if 'IAM' in k.upper() or 'ENFORCE' in k.upper()}, indent=2))" 2>&1 | head -40{"version": "2026.3.0:e28f8cfa4", "edition": "pro", "is_license_activated": true, "session_id": "be781f1c-3eab-48eb-a882-55f29b4934e9", "machine_id": "dkr_dc773c0a2fb6", "system": "Ubuntu 24.04.4 LTS,6.1.158,x86_64", "is_docker": true, "server_time_utc": "2026-04-29T17:46:02", "uptime": 446}---
/bin/bash: line 1: docker: command not found
---env---
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]
{"version": "2026.3.0:e28f8cfa4", "edition": "pro", "is_license_activated": true, "session_id": "be781f1c-3eab-48eb-a882-55f29b4934e9", "machine_id": "dkr_dc773c0a2fb6", "system": "Ubuntu 24.04.4 LTS,6.1.158,x86_64", "is_docker": true, "server_time_utc": "2026-04-29T17:46:02", "uptime": 446}---
/bin/bash: line 1: docker: command not found
---env---
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)Get LocalStack runtime config
curl -s http://localstack:4566/_localstack/config 2>&1 | python3 -c "import sys,json; d=json.load(sys.stdin); [print(k,'=',v) for k,v in d.items() if 'iam' in k.lower() or 'enforc' in k.lower()]" 2>&1 | head -40
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]
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)Look at diagnose output raw
curl -s -o /tmp/lsdiag http://localstack:4566/_localstack/diagnose; head -c 200 /tmp/lsdiag; echo "..."; wc -c /tmp/lsdiag
... 0 /tmp/lsdiag [stdout] ... 0 /tmp/lsdiag
Manually assume data role with tag , does LocalStack accept it
aws sts assume-role \ --role-arn arn:aws:iam::000000000000:role/TenantDataRole \ --role-session-name dbg-acme \ --duration-seconds 900 \ --tags Key=TenantID,Value=acme \ --transitive-tag-keys TenantID 2>&1 | head -30
{
"Credentials": {
"AccessKeyId": "LSIAQAAAAAAAIJYECMO3",
"SecretAccessKey": "ELdFI2m+AK4P6NMgLZ8lvCnPT2WFuCOQ3yMswJXC",
"SessionToken": "FQoGZXIvYXdzERjMzkxZMUl95SIVgRWpHydd9I/1BkS26EknYJqNtCoHc4nJdWjkZ0tlAo3W954VvKhbkg8VdH9+NF3U8DNNoTOuOtXEx0dwKvNyaODRgAy5lW7grJzWOUFycqncXwzLKbTgzLNCzYHWMASLUQTymzP369rbUZ9HAEiFiUdJl5pCE6IaUJirDziE0SKasVTXHZMTlZYcV9d9CqV/8QEim6efUcIqp9zUs7JuHN0ZF5qI5VYzNtCRjGeZDUDvf1orpfLxS6YK1GPEVA4b7J1S2nb9UFsLiMmJyRX5k37EL6FrImsa0kmdT1Vk=lz5KXrOnBgewr=8YBWVEDpWd27cpPmg",
"Expiration": "2026-04-29T18:01:34.265807+00:00"
},
"AssumedRoleUser": {
"AssumedRoleId": "AROAQAAAAAAACYQLG4ERC:dbg-acme",
"Arn": "arn:aws:sts::000000000000:assumed-role/TenantDataRole/dbg-acme"
}
}
[stdout]
{
"Credentials": {
"AccessKeyId": "LSIAQAAAAAAAIJYECMO3",
"SecretAccessKey": "ELdFI2m+AK4P6NMgLZ8lvCnPT2WFuCOQ3yMswJXC",
"SessionToken": "FQoGZXIvYXdzERjMzkxZMUl95SIVgRWpHydd9I/1BkS26EknYJqNtCoHc4nJdWjkZ0tlAo3W954VvKhbkg8VdH9+NF3U8DNNoTOuOtXEx0dwKvNyaODRgAy5lW7grJzWOUFycqncXwzLKbTgzLNCzYHWMASLUQTymzP369rbUZ9HAEiFiUdJl5pCE6IaUJirDziE0SKasVTXHZMTlZYcV9d9CqV/8QEim6efUcIqp9zUs7JuHN0ZF5qI5VYzNtCRjGeZDUDvf1orpfLxS6YK1GPEVA4b7J1S2nb9UFsLiMmJyRX5k37EL6FrImsa0kmdT1Vk=lz5KXrOnBgewr=8YBWVEDpWd27cpPmg",
"Expiration": "2026-04-29T18:01:34.265807+00:00"
},
"AssumedRoleUser": {
"AssumedRoleId": "AROAQAAAAAAACYQLG4ERC:dbg-acme",
"Arn": "arn:aws:sts::000000000000:assumed-role/TenantDataRole/dbg-acme"
}
}Verify trust policy denies untagged assume
# try assume WITHOUT tags , should be denied per trust policy aws sts assume-role \ --role-arn arn:aws:iam::000000000000:role/TenantDataRole \ --role-session-name dbg-notag \ --duration-seconds 900 2>&1 | tail -3
"Arn": "arn:aws:sts::000000000000:assumed-role/TenantDataRole/dbg-notag"
}
}
[stdout]
"Arn": "arn:aws:sts::000000000000:assumed-role/TenantDataRole/dbg-notag"
}
}Check LocalStack config endpoint for IAM enforcement
curl -sS http://localstack:4566/_localstack/config 2>&1 | head -200; echo; echo "---env grep---"; env | grep -iE "iam|enforce|local"
---env grep--- AWS_ENDPOINT_URL=http://localstack:4566 CLAUDE_CODE_EXECPATH=/root/.local/share/claude/versions/2.1.123 PATH=/root/.local/bin:/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin [stdout] ---env grep--- AWS_ENDPOINT_URL=http://localstack:4566 CLAUDE_CODE_EXECPATH=/root/.local/share/claude/versions/2.1.123 PATH=/root/.local/bin:/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Probe localstack config endpoint
curl -sS -o /tmp/cfg -w "%{http_code}\n" http://localstack:4566/_localstack/config; echo; head -c 500 /tmp/cfg; echo; echo "----"; curl -sS http://localstack:4566/?Action=GetCallerIdentity 2>&1 | head -5404 ---- <?xml version='1.0' encoding='utf-8'?> <GetCallerIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/"><GetCallerIdentityResult><UserId>000000000000</UserId><Account>000000000000</Account><Arn>arn:aws:iam::000000000000:root</Arn></GetCallerIdentityResult><ResponseMetadata><RequestId>11f08a26-0bc0-4525-8c22-21d692b717f6</RequestId></ResponseMetadata></GetCallerIdentityResponse> [stdout] 404 ---- <?xml version='1.0' encoding='utf-8'?> <GetCallerIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/"><GetCallerIdentityResult><UserId>000000000000</UserId><Account>000000000000</Account><Arn>arn:aws:iam::000000000000:root</Arn></GetCallerIdentityResult><ResponseMetadata><RequestId>11f08a26-0bc0-4525-8c22-21d692b717f6</RequestId></ResponseMetadata></GetCallerIdentityResponse>
Probe additional localstack endpoints
curl -sS http://localstack:4566/_localstack/init 2>&1 | head -50; echo "---"; curl -sS -o /tmp/c -w "%{http_code}\n" "http://localstack:4566/_localstack/state"; head -c 200 /tmp/c; echo; echo "---"; curl -sS -o /tmp/d -w "%{http_code}\n" "http://localstack:4566/_aws/diagnose"; head -c 200 /tmp/d{"completed": {"BOOT": true, "START": true, "READY": true, "SHUTDOWN": false}, "scripts": []}---
404
---
404
<?xml version='1.0' encoding='utf-8'?>
<Error><Code>NoSuchBucket</Code><Message>The specified bucket does not exist</Message><RequestId>99b079d0-ed33-4041-b3f5-aed39f649207</RequestId><BucketName>_aws
[stdout]
{"completed": {"BOOT": true, "START": true, "READY": true, "SHUTDOWN": false}, "scripts": []}---
404
---
404
<?xml version='1.0' encoding='utf-8'?>
<Error><Code>NoSuchBucket</Code><Message>The specified bucket does not exist</Message><RequestId>99b079d0-ed33-4041-b3f5-aed39f649207</RequestId><BucketName>_awsConfirm trust conditions fire for forbidden values
# try to assume with a forbidden tenant value , trust policy should reject "evil" aws sts assume-role \ --role-arn arn:aws:iam::000000000000:role/TenantDataRole \ --role-session-name dbg-evil \ --duration-seconds 900 \ --tags Key=TenantID,Value=evil 2>&1 | tail -3 echo "---" # what about tag key that's not TenantID aws sts assume-role \ --role-arn arn:aws:iam::000000000000:role/TenantDataRole \ --role-session-name dbg-other \ --duration-seconds 900 \ --tags Key=NotTenant,Value=acme 2>&1 | tail -3
"Arn": "arn:aws:sts::000000000000:assumed-role/TenantDataRole/dbg-evil"
}
}
---
"Arn": "arn:aws:sts::000000000000:assumed-role/TenantDataRole/dbg-other"
}
}
[stdout]
"Arn": "arn:aws:sts::000000000000:assumed-role/TenantDataRole/dbg-evil"
}
}
---
"Arn": "arn:aws:sts::000000000000:assumed-role/TenantDataRole/dbg-other"
}
}Inspect deployed tenant data role policy
aws iam get-role-policy --role-name TenantDataRole --policy-name tenant-data-inline --query 'PolicyDocument' 2>&1 | python3 -m json.tool
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TenantScopedReads",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query"
],
"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": "TenantScopedWrites",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
]
}
}
},
{
"Sid": "DenyPasswordAttributeAlways",
"Effect": "Deny",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAnyValue:StringEquals": {
"dynamodb:Attributes": [
"password"
]
}
}
}
]
}
[stdout]
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TenantScopedReads",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query"
],
"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": "TenantScopedWrites",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
]
}
}
},
{
"Sid": "DenyPasswordAttributeAlways",
"Effect": "Deny",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAnyValue:StringEquals": {
"dynamodb:Attributes": [
"password"
]
}
}
}
]
}Simulate cross-tenant query via IAM simulator
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::000000000000:role/TenantDataRole \
--action-names dynamodb:Query \
--resource-arns arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders \
--context-entries \
'ContextKeyName=aws:PrincipalTag/TenantID,ContextKeyValues=acme,ContextKeyType=string' \
'ContextKeyName=dynamodb:LeadingKeys,ContextKeyValues=globex,ContextKeyType=string' \
2>&1 | tail -20{
"EvaluationResults": [
{
"EvalActionName": "dynamodb:Query",
"EvalResourceName": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"EvalDecision": "implicitDeny"
}
]
}
[stdout]
{
"EvaluationResults": [
{
"EvalActionName": "dynamodb:Query",
"EvalResourceName": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"EvalDecision": "implicitDeny"
}
]
}Run additional simulator checks
echo "=== same-tenant query (acme) WITH proper projection , expect allowed"
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::000000000000:role/TenantDataRole \
--action-names dynamodb:Query \
--resource-arns arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders \
--context-entries \
'ContextKeyName=aws:PrincipalTag/TenantID,ContextKeyValues=acme,ContextKeyType=string' \
'ContextKeyName=dynamodb:LeadingKeys,ContextKeyValues=acme,ContextKeyType=string' \
'ContextKeyName=dynamodb:Attributes,ContextKeyValues=TenantID,OrderID,amount,ContextKeyType=stringList' \
'ContextKeyName=dynamodb:Select,ContextKeyValues=SPECIFIC_ATTRIBUTES,ContextKeyType=string' \
2>&1 | tail -10
echo
echo "=== query asking for password , expect denied"
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::000000000000:role/TenantDataRole \
--action-names dynamodb:Query \
--resource-arns arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders \
--context-entries \
'ContextKeyName=aws:PrincipalTag/TenantID,ContextKeyValues=acme,ContextKeyType=string' \
'ContextKeyName=dynamodb:LeadingKeys,ContextKeyValues=acme,ContextKeyType=string' \
'ContextKeyName=dynamodb:Attributes,ContextKeyValues=TenantID,OrderID,password,ContextKeyType=stringList' \
'ContextKeyName=dynamodb:Select,ContextKeyValues=SPECIFIC_ATTRIBUTES,ContextKeyType=string' \
2>&1 | tail -10
echo
echo "=== Scan , expect denied"
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::000000000000:role/TenantDataRole \
--action-names dynamodb:Scan \
--resource-arns arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders \
2>&1 | tail -10=== same-tenant query (acme) WITH proper projection , expect allowed
{
"EvaluationResults": [
{
"EvalActionName": "dynamodb:Query",
"EvalResourceName": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"EvalDecision": "implicitDeny"
}
]
}
=== query asking for password , expect denied
{
"EvaluationResults": [
{
"EvalActionName": "dynamodb:Query",
"EvalResourceName": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"EvalDecision": "explicitDeny"
}
]
}
=== Scan , expect denied
{
"EvaluationResults": [
{
"EvalActionName": "dynamodb:Scan",
"EvalResourceName": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"EvalDecision": "implicitDeny"
}
]
}
[stdout]
=== same-tenant query (acme) WITH proper projection , expect allowed
{
"EvaluationResults": [
{
"EvalActionName": "dynamodb:Query",
"EvalResourceName": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"EvalDecision": "implicitDeny"
}
]
}
=== query asking for password , expect denied
{
"EvaluationResults": [
{
"EvalActionName": "dynamodb:Query",
"EvalResourceName": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"EvalDecision": "explicitDeny"
}
]
}
=== Scan , expect denied
{
"EvaluationResults": [
{
"EvalActionName": "dynamodb:Scan",
"EvalResourceName": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"EvalDecision": "implicitDeny"
}
]
}Probe simulator behavior without all context
echo "=== Query with NO conditions context , does the Allow match at all?"
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::000000000000:role/TenantDataRole \
--action-names dynamodb:Query \
--resource-arns arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders \
2>&1 | tail -10
echo
echo "=== same-tenant query with PrincipalTag and LeadingKeys but no Attributes/Select"
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::000000000000:role/TenantDataRole \
--action-names dynamodb:Query \
--resource-arns arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders \
--context-entries \
'ContextKeyName=aws:PrincipalTag/TenantID,ContextKeyValues=acme,ContextKeyType=string' \
'ContextKeyName=dynamodb:LeadingKeys,ContextKeyValues=acme,ContextKeyType=string' \
2>&1 | tail -10=== Query with NO conditions context , does the Allow match at all?
{
"EvaluationResults": [
{
"EvalActionName": "dynamodb:Query",
"EvalResourceName": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"EvalDecision": "allowed"
}
]
}
=== same-tenant query with PrincipalTag and LeadingKeys but no Attributes/Select
{
"EvaluationResults": [
{
"EvalActionName": "dynamodb:Query",
"EvalResourceName": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"EvalDecision": "implicitDeny"
}
]
}
[stdout]
=== Query with NO conditions context , does the Allow match at all?
{
"EvaluationResults": [
{
"EvalActionName": "dynamodb:Query",
"EvalResourceName": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"EvalDecision": "allowed"
}
]
}
=== same-tenant query with PrincipalTag and LeadingKeys but no Attributes/Select
{
"EvaluationResults": [
{
"EvalActionName": "dynamodb:Query",
"EvalResourceName": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"EvalDecision": "implicitDeny"
}
]
}#!/usr/bin/env bash
set -euo pipefail
API_URL=$(aws ssm get-parameter --name /harbor/saas/api-url --query 'Parameter.Value' --output text)
DATA_ROLE_ARN=$(aws ssm get-parameter --name /harbor/saas/role-arn --query 'Parameter.Value' --output text)
TABLE_ARN="arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders"
PASS=0; FAIL=0
chk() { if [[ "$1" == "$2" ]]; then echo "PASS $3"; PASS=$((PASS+1)); else echo "FAIL $3 (got $1, want $2)"; FAIL=$((FAIL+1)); fi; }
echo "API URL = ${API_URL}"
echo
echo "=== A) bad tenant id -> HTTP 400"
CODE=$(curl -sS -o /tmp/bad -w "%{http_code}" "${API_URL}?tenant=ACME!" || echo "000")
chk "${CODE}" "400" "bad tenant id rejected"
echo
echo "=== B) good tenant id -> HTTP 200 with credential keys"
CODE=$(curl -sS -o /tmp/ok -w "%{http_code}" "${API_URL}?tenant=acme")
chk "${CODE}" "200" "good tenant id accepted"
HASKEYS=$(python3 -c "
import json
d=json.load(open('/tmp/ok'))
need={'AccessKeyId','SecretAccessKey','SessionToken','Expiration','TenantID'}
print('yes' if need.issubset(d) else 'no')")
chk "${HASKEYS}" "yes" "response carries all credential fields"
AK=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['AccessKeyId'])")
SK=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['SecretAccessKey'])")
ST=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['SessionToken'])")
TENID=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['TenantID'])")
chk "${TENID}" "acme" "tenant echoed back"
echo
echo "=== C) credential body must NOT have appeared in lambda CloudWatch logs"
LOG_GROUP="/aws/lambda/tenant-token-vendor"
STREAMS=$(aws logs describe-log-streams --log-group-name "${LOG_GROUP}" --query 'logStreams[].logStreamName' --output text 2>/dev/null || true)
LEAK=0
for s in ${STREAMS}; do
EVENTS=$(aws logs get-log-events --log-group-name "${LOG_GROUP}" --log-stream-name "${s}" --query 'events[].message' --output text 2>/dev/null || true)
for needle in "${AK}" "${SK}" "${ST}"; do
if grep -F -- "${needle}" <<<"${EVENTS}" >/dev/null; then LEAK=1; fi
done
done
chk "${LEAK}" "0" "no credential leakage in CloudWatch"
echo
echo "=== D) auditor-style policy walk via IAM simulator"
simulate() {
local action="$1"; shift
local label="$1"; shift
aws iam simulate-principal-policy \
--policy-source-arn "${DATA_ROLE_ARN}" \
--action-names "${action}" \
--resource-arns "${TABLE_ARN}" \
--context-entries "$@" \
--query 'EvaluationResults[0].EvalDecision' --output text 2>/dev/null
}
D=$(simulate dynamodb:Query "cross-tenant" \
'ContextKeyName=aws:PrincipalTag/TenantID,ContextKeyValues=acme,ContextKeyType=string' \
'ContextKeyName=dynamodb:LeadingKeys,ContextKeyValues=globex,ContextKeyType=string')
[[ "${D}" == "implicitDeny" || "${D}" == "explicitDeny" ]] && chk "deny" "deny" "cross-tenant Query denied" || chk "${D}" "deny" "cross-tenant Query denied"
D=$(simulate dynamodb:Query "password-attr" \
'ContextKeyName=aws:PrincipalTag/TenantID,ContextKeyValues=acme,ContextKeyType=string' \
'ContextKeyName=dynamodb:LeadingKeys,ContextKeyValues=acme,ContextKeyType=string' \
'ContextKeyName=dynamodb:Attributes,ContextKeyValues=password,ContextKeyType=stringList' \
'ContextKeyName=dynamodb:Select,ContextKeyValues=SPECIFIC_ATTRIBUTES,ContextKeyType=string')
chk "${D}" "explicitDeny" "password attribute explicitly denied"
D=$(simulate dynamodb:Scan "scan-not-allowed")
[[ "${D}" == "implicitDeny" || "${D}" == "explicitDeny" ]] && chk "deny" "deny" "Scan denied" || chk "${D}" "deny" "Scan denied"
echo
echo "=== E) data-plane: real assumed-role tries cross-tenant Query (best-effort)"
set +e
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" >/tmp/x 2>/tmp/xe
RC=$?
set -e
if [[ ${RC} -ne 0 ]] || grep -qi "AccessDenied" /tmp/xe; then
echo "PASS cross-tenant Query data-plane denied"; PASS=$((PASS+1))
else
echo "INFO cross-tenant Query allowed by LocalStack data-plane (LocalStack does not always enforce dynamodb:LeadingKeys); auditor walk above is authoritative"
fi
echo
echo "=== F) data-plane: Scan (must deny by missing-action)"
set +e
AWS_ACCESS_KEY_ID="${AK}" AWS_SECRET_ACCESS_KEY="${SK}" AWS_SESSION_TOKEN="${ST}" \
aws dynamodb scan --table-name SaasOrders >/tmp/y 2>/tmp/ye
RC=$?
set -e
if grep -qi "AccessDenied" /tmp/ye; then chk "deny" "deny" "Scan denied at data-plane"; else chk "allow" "deny" "Scan denied at data-plane"; fi
echo
echo "=== summary: ${PASS} pass / ${FAIL} fail"
exit ${FAIL}
Run reorganized smoke test
bash /app/scripts/smoketest.sh 2>&1
API URL = http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token === A) bad tenant id -> HTTP 400 PASS bad tenant id rejected === B) good tenant id -> HTTP 200 with credential keys PASS good tenant id accepted PASS response carries all credential fields PASS tenant echoed back === C) credential body must NOT have appeared in lambda CloudWatch logs PASS no credential leakage in CloudWatch === D) auditor-style policy walk via IAM simulator PASS cross-tenant Query denied PASS password attribute explicitly denied PASS Scan denied === E) data-plane: real assumed-role tries cross-tenant Query (best-effort) INFO cross-tenant Query allowed by LocalStack data-plane (LocalStack does not always enforce dynamodb:LeadingKeys); auditor walk above is authoritative === F) data-plane: Scan (must deny by missing-action) PASS Scan denied at data-plane === summary: 9 pass / 0 fail [stdout] API URL = http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token === A) bad tenant id -> HTTP 400 PASS bad tenant id rejected === B) good tenant id -> HTTP 200 with credential keys PASS good tenant id accepted PASS response carries all credential fields PASS tenant echoed back === C) credential body must NOT have appeared in lambda CloudWatch logs PASS no credential leakage in CloudWatch === D) auditor-style policy walk via IAM simulator PASS cross-tenant Query denied PASS password attribute explicitly denied PASS Scan denied === E) data-plane: real assumed-role tries cross-tenant Query (best-effort) INFO cross-tenant Query allowed by LocalStack data-plane (LocalStack does not always enforce dynamodb:LeadingKeys); auditor walk above is authoritative === F) data-plane: Scan (must deny by missing-action) PASS Scan denied at data-plane === summary: 9 pass / 0 fail
/app/policies/tenant-data-role-policy.json
{
"Sid": "TenantScopedReads",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query"
],
"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": "TenantScopedWrites",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
]
}
}
}, {
"Sid": "TenantScopedReads",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query"
],
"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"
},
"Null": {
"aws:PrincipalTag/TenantID": "false"
}
}
},
{
"Sid": "TenantScopedWrites",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
]
},
"Null": {
"aws:PrincipalTag/TenantID": "false"
}
}
},
{
"Sid": "DenyCrossTenantLeadingKeys",
"Effect": "Deny",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAnyValue:StringNotEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
]
}
}
},Update data-role policy with explicit cross-tenant deny and rerun
aws iam put-role-policy --role-name TenantDataRole --policy-name tenant-data-inline --policy-document file:///app/policies/tenant-data-role-policy.json && bash /app/scripts/smoketest.sh 2>&1 | tail -25
API URL = http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token === A) bad tenant id -> HTTP 400 PASS bad tenant id rejected === B) good tenant id -> HTTP 200 with credential keys PASS good tenant id accepted PASS response carries all credential fields PASS tenant echoed back === C) credential body must NOT have appeared in lambda CloudWatch logs PASS no credential leakage in CloudWatch === D) auditor-style policy walk via IAM simulator PASS cross-tenant Query denied PASS password attribute explicitly denied PASS Scan denied === E) data-plane: real assumed-role tries cross-tenant Query (best-effort) PASS cross-tenant Query data-plane denied === F) data-plane: Scan (must deny by missing-action) PASS Scan denied at data-plane === summary: 10 pass / 0 fail [stdout] API URL = http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token === A) bad tenant id -> HTTP 400 PASS bad tenant id rejected === B) good tenant id -> HTTP 200 with credential keys PASS good tenant id accepted PASS response carries all credential fields PASS tenant echoed back === C) credential body must NOT have appeared in lambda CloudWatch logs PASS no credential leakage in CloudWatch === D) auditor-style policy walk via IAM simulator PASS cross-tenant Query denied PASS password attribute explicitly denied PASS Scan denied === E) data-plane: real assumed-role tries cross-tenant Query (best-effort) PASS cross-tenant Query data-plane denied === F) data-plane: Scan (must deny by missing-action) PASS Scan denied at data-plane === summary: 10 pass / 0 fail
Confirm legitimate query paths still work and password is denied at runtime
curl -sS "http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token?tenant=acme" > /tmp/ok
AK=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['AccessKeyId'])")
SK=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['SecretAccessKey'])")
ST=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['SessionToken'])")
echo "=== same-tenant Query (acme) -> 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" 2>&1 | tail -15
echo
echo "=== same-tenant Query 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"}' 2>&1 | tail -3
echo
echo "=== globex tenant cross-query their own rows -> SHOULD succeed"
curl -sS "http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token?tenant=globex-eu" > /tmp/g
GAK=$(python3 -c "import json;print(json.load(open('/tmp/g'))['AccessKeyId'])")
GSK=$(python3 -c "import json;print(json.load(open('/tmp/g'))['SecretAccessKey'])")
GST=$(python3 -c "import json;print(json.load(open('/tmp/g'))['SessionToken'])")
AWS_ACCESS_KEY_ID="${GAK}" AWS_SECRET_ACCESS_KEY="${GSK}" AWS_SESSION_TOKEN="${GST}" \
aws dynamodb query --table-name SaasOrders \
--key-condition-expression "TenantID = :t" \
--expression-attribute-values '{":t":{"S":"globex-eu"}}' \
--projection-expression "TenantID,OrderID,amount" 2>&1 | tail -10=== same-tenant Query (acme) -> SHOULD succeed aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action === same-tenant Query asking for password -> SHOULD deny aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action === globex tenant cross-query their own rows -> SHOULD succeed aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-globex-eu is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action [stdout] === same-tenant Query (acme) -> SHOULD succeed aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action === same-tenant Query asking for password -> SHOULD deny aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action === globex tenant cross-query their own rows -> SHOULD succeed aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-globex-eu is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action
/app/policies/tenant-data-role-policy.json
"Null": {
"aws:PrincipalTag/TenantID": "false"
} "Null": {
"dynamodb:LeadingKeys": "false"
}Verify same-tenant works while cross-tenant and password remain denied
aws iam put-role-policy --role-name TenantDataRole --policy-name tenant-data-inline --policy-document file:///app/policies/tenant-data-role-policy.json
echo "--- redeployed policy"
# fresh creds
curl -sS "http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token?tenant=acme" > /tmp/ok
AK=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['AccessKeyId'])")
SK=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['SecretAccessKey'])")
ST=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['SessionToken'])")
echo
echo "=== same-tenant (acme) -> expect SUCCESS"
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" 2>&1 | tail -10
echo
echo "=== cross-tenant (globex) -> expect 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" 2>&1 | tail -3
echo
echo "=== request password -> expect 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"}' 2>&1 | tail -3--- redeployed policy === same-tenant (acme) -> expect SUCCESS aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action === cross-tenant (globex) -> expect DENY aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action === request password -> expect DENY aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action [stdout] --- redeployed policy === same-tenant (acme) -> expect SUCCESS aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action === cross-tenant (globex) -> expect DENY aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action === request password -> expect DENY aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action
/app/policies/tenant-data-role-policy.json
{
"Sid": "TenantScopedReads",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query"
],
"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"
},
"Null": {
"dynamodb:LeadingKeys": "false"
}
}
},
{
"Sid": "TenantScopedWrites",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
]
},
"Null": {
"dynamodb:LeadingKeys": "false"
}
}
}, {
"Sid": "TenantScopedReads",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query"
],
"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": "TenantScopedWrites",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
]
}
}
},Verify all three runtime cases
aws iam put-role-policy --role-name TenantDataRole --policy-name tenant-data-inline --policy-document file:///app/policies/tenant-data-role-policy.json
echo "--- redeployed"
curl -sS "http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token?tenant=acme" > /tmp/ok
AK=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['AccessKeyId'])")
SK=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['SecretAccessKey'])")
ST=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['SessionToken'])")
echo
echo "=== same-tenant -> SUCCESS"
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" 2>&1 | tail -10
echo
echo "=== cross-tenant -> 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" 2>&1 | tail -3
echo
echo "=== ask password -> 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"}' 2>&1 | tail -3--- redeployed === same-tenant -> SUCCESS aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action === cross-tenant -> DENY aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action === ask password -> DENY aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action [stdout] --- redeployed === same-tenant -> SUCCESS aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action === cross-tenant -> DENY aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action === ask password -> DENY aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Query operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/tenant-acme is not authorized to perform: dynamodb:Query on resource: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders because no identity-based policy allows the dynamodb:Query action
/app/policies/tenant-data-role-policy.json
{
"Sid": "DenyCrossTenantLeadingKeys",
"Effect": "Deny",
"Action": [
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:Query",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAnyValue:StringNotEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
]
}
}
},
{
"Sid": "DenyPasswordAttributeAlways", {
"Sid": "DenyPasswordAttributeAlways",Test without explicit cross-tenant Deny
aws iam put-role-policy --role-name TenantDataRole --policy-name tenant-data-inline --policy-document file:///app/policies/tenant-data-role-policy.json
echo "--- redeployed (no explicit cross-tenant Deny)"
curl -sS "http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token?tenant=acme" > /tmp/ok
AK=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['AccessKeyId'])")
SK=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['SecretAccessKey'])")
ST=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['SessionToken'])")
echo
echo "=== same-tenant (acme) -> SUCCESS?"
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" 2>&1 | tail -10
echo
echo "=== cross-tenant (globex) -> 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" 2>&1 | tail -3--- redeployed (no explicit cross-tenant Deny)
=== same-tenant (acme) -> SUCCESS?
},
"amount": {
"N": "3400"
}
}
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
=== cross-tenant (globex) -> DENY?
"ScannedCount": 2,
"ConsumedCapacity": null
}
[stdout]
--- redeployed (no explicit cross-tenant Deny)
=== same-tenant (acme) -> SUCCESS?
},
"amount": {
"N": "3400"
}
}
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
=== cross-tenant (globex) -> DENY?
"ScannedCount": 2,
"ConsumedCapacity": null
}Test if LocalStack evaluates hardcoded LeadingKeys condition
cat > /tmp/test-policy.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["dynamodb:Query", "dynamodb:GetItem"],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["acme"]
}
}
}
]
}
EOF
aws iam put-role-policy --role-name TenantDataRole --policy-name tenant-data-inline --policy-document file:///tmp/test-policy.json
echo "--- HARDCODED policy: only LeadingKeys=acme allowed"
curl -sS "http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token?tenant=acme" > /tmp/ok
AK=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['AccessKeyId'])")
SK=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['SecretAccessKey'])")
ST=$(python3 -c "import json;print(json.load(open('/tmp/ok'))['SessionToken'])")
echo
echo "=== acme query (LeadingKeys=acme) -> SUCCESS expected"
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
echo "=== globex query (LeadingKeys=globex) -> DENY expected"
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 -3--- HARDCODED policy: only LeadingKeys=acme allowed
=== acme query (LeadingKeys=acme) -> SUCCESS expected
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
=== globex query (LeadingKeys=globex) -> DENY expected
"ScannedCount": 2,
"ConsumedCapacity": null
}
[stdout]
--- HARDCODED policy: only LeadingKeys=acme allowed
=== acme query (LeadingKeys=acme) -> SUCCESS expected
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
=== globex query (LeadingKeys=globex) -> DENY expected
"ScannedCount": 2,
"ConsumedCapacity": null
}Probe LocalStack endpoints for config info
curl -sS http://localstack:4566/_localstack/services 2>&1 | head -40 echo "---" curl -sS "http://localstack:4566/_localstack/?action=describe-config" 2>&1 | head -40 echo "---" # Try to check what IAM-related env vars the LocalStack container knows about curl -sS http://localstack:4566/_localstack/diagnose 2>&1 | head -100
--- --- [stdout] --- ---
Re-probe diagnose
curl -sS -o /tmp/diag http://localstack:4566/_localstack/diagnose -w "%{http_code}\n"; ls -la /tmp/diag; head -c 4000 /tmp/diag404 -rw-r--r-- 1 root root 0 Apr 29 17:53 /tmp/diag [stdout] 404 -rw-r--r-- 1 root root 0 Apr 29 17:53 /tmp/diag
/app/policies/tenant-data-role-policy.json
1 {
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Sid": "TenantScopedReads",
6 "Effect": "Allow",
7 "Action": [
8 "dynamodb:GetItem",
9 "dynamodb:BatchGetItem",
10 "dynamodb:Query"
11 ],
12 "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
13 "Condition": {
14 "ForAllValues:StringEquals": {
15 "dynamodb:LeadingKeys": [
16 "${aws:PrincipalTag/TenantID}"
17 ],
18 "dynamodb:Attributes": [
19 "TenantID",
20 "OrderID",
21 "amount"
22 ]
23 },
24 "StringEqualsIfExists": {
25 "dynamodb:Select": "SPECIFIC_ATTRIBUTES"
26 }
27 }
28 },
29 {
30 "Sid": "TenantScopedWrites",
31 "Effect": "Allow",
32 "Action": [
33 "dynamodb:PutItem",
34 "dynamodb:UpdateItem",
35 "dynamodb:DeleteItem"
36 ],
37 "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
38 "Condition": {
39 "ForAllValues:StringEquals": {
40 "dynamodb:LeadingKeys": [
41 "${aws:PrincipalTag/TenantID}"
42 ]
43 }
44 }
45 },
46 {
47 "Sid": "DenyPasswordAttributeAlways",
48 "Effect": "Deny",
49 "Action": [
50 "dynamodb:GetItem",
51 "dynamodb:BatchGetItem",
52 "dynamodb:Query",
53 "dynamodb:PutItem",
54 "dynamodb:UpdateItem"
55 ],
56 "Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
57 "Condition": {
58 "ForAnyValue:StringEquals": {
59 "dynamodb:Attributes": [
60 "password"
61 ]
62 }
63 }
64 }
65 ]
66 }
67
Run simulator probes
# manually assume + use creds + simulate to see what context the simulator
# considers for an ASSUMED-role principal vs what data-plane sees
aws sts assume-role \
--role-arn arn:aws:iam::000000000000:role/TenantDataRole \
--role-session-name probe \
--duration-seconds 900 \
--tags Key=TenantID,Value=acme \
--transitive-tag-keys TenantID > /tmp/ar.json
ARN=$(python3 -c "import json;print(json.load(open('/tmp/ar.json'))['AssumedRoleUser']['Arn'])")
echo "assumed role arn: ${ARN}"
echo
echo "=== simulate DDB Query against the data role using SOURCE_ARN of the role itself"
echo " no context entries , tells us LocalStack's default eval w/o explicit context"
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::000000000000:role/TenantDataRole \
--action-names dynamodb:Query \
--resource-arns arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders \
--query 'EvaluationResults[0].EvalDecision' --output text
echo
echo "=== simulate-custom-policy also exists; try with our cross-tenant context"
aws iam simulate-custom-policy \
--policy-input-list "$(cat /app/policies/tenant-data-role-policy.json | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read()))')" \
--action-names dynamodb:Query \
--resource-arns arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders \
--context-entries \
'ContextKeyName=aws:PrincipalTag/TenantID,ContextKeyValues=acme,ContextKeyType=string' \
'ContextKeyName=dynamodb:LeadingKeys,ContextKeyValues=globex,ContextKeyType=string' \
--query 'EvaluationResults[0].EvalDecision' --output text 2>&1 | tail -3assumed role arn: arn:aws:sts::000000000000:assumed-role/TenantDataRole/probe
=== simulate DDB Query against the data role using SOURCE_ARN of the role itself
no context entries , tells us LocalStack's default eval w/o explicit context
allowed
=== simulate-custom-policy also exists; try with our cross-tenant context
aws: [ERROR]: An error occurred (InternalFailure) when calling the SimulateCustomPolicy operation: Sorry, the SimulateCustomPolicy operation on the iam service is not currently supported by LocalStack.
[stdout]
assumed role arn: arn:aws:sts::000000000000:assumed-role/TenantDataRole/probe
=== simulate DDB Query against the data role using SOURCE_ARN of the role itself
no context entries , tells us LocalStack's default eval w/o explicit context
allowed
=== simulate-custom-policy also exists; try with our cross-tenant context
aws: [ERROR]: An error occurred (InternalFailure) when calling the SimulateCustomPolicy operation: Sorry, the SimulateCustomPolicy operation on the iam service is not currently supported by LocalStack.Reapply clean policy and run final smoketest
aws iam put-role-policy --role-name TenantDataRole --policy-name tenant-data-inline --policy-document file:///app/policies/tenant-data-role-policy.json echo "policy reapplied" echo bash /app/scripts/smoketest.sh 2>&1 | tail -25
policy reapplied API URL = http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token === A) bad tenant id -> HTTP 400 PASS bad tenant id rejected === B) good tenant id -> HTTP 200 with credential keys PASS good tenant id accepted PASS response carries all credential fields PASS tenant echoed back === C) credential body must NOT have appeared in lambda CloudWatch logs PASS no credential leakage in CloudWatch === D) auditor-style policy walk via IAM simulator PASS cross-tenant Query denied PASS password attribute explicitly denied PASS Scan denied === E) data-plane: real assumed-role tries cross-tenant Query (best-effort) INFO cross-tenant Query allowed by LocalStack data-plane (LocalStack does not always enforce dynamodb:LeadingKeys); auditor walk above is authoritative === F) data-plane: Scan (must deny by missing-action) PASS Scan denied at data-plane === summary: 9 pass / 0 fail [stdout] policy reapplied API URL = http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token === A) bad tenant id -> HTTP 400 PASS bad tenant id rejected === B) good tenant id -> HTTP 200 with credential keys PASS good tenant id accepted PASS response carries all credential fields PASS tenant echoed back === C) credential body must NOT have appeared in lambda CloudWatch logs PASS no credential leakage in CloudWatch === D) auditor-style policy walk via IAM simulator PASS cross-tenant Query denied PASS password attribute explicitly denied PASS Scan denied === E) data-plane: real assumed-role tries cross-tenant Query (best-effort) INFO cross-tenant Query allowed by LocalStack data-plane (LocalStack does not always enforce dynamodb:LeadingKeys); auditor walk above is authoritative === F) data-plane: Scan (must deny by missing-action) PASS Scan denied at data-plane === summary: 9 pass / 0 fail
Verify final state of resources
echo "=== DDB encryption + PITR"
aws dynamodb describe-table --table-name SaasOrders --query 'Table.SSEDescription' --output json
aws dynamodb describe-continuous-backups --table-name SaasOrders --query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus' --output text
echo
echo "=== KMS alias points to non-aws-managed key"
aws kms describe-key --key-id alias/saas-orders-cmk --query '{Manager:KeyMetadata.KeyManager,Arn:KeyMetadata.Arn}'
echo
echo "=== vendor exec role has NO managed policy attached"
aws iam list-attached-role-policies --role-name TenantTokenVendorRole
echo
echo "=== SSM parameters"
for p in /harbor/saas/table-name /harbor/saas/role-arn /harbor/saas/vendor-role-arn /harbor/saas/lambda-arn /harbor/saas/api-id /harbor/saas/api-url; do
v=$(aws ssm get-parameter --name "$p" --query 'Parameter.Value' --output text 2>/dev/null)
printf " %-32s = %s\n" "$p" "$v"
done
echo
echo "=== APIGW stage access log + redeployed"
aws apigateway get-stage --rest-api-id 4ftewofzbt --stage-name prod --query '{logArn:accessLogSettings.destinationArn,logFmt:accessLogSettings.format,deploymentId:deploymentId}' --output json
echo
echo "=== Lambda permission SourceArn pinning"
aws lambda get-policy --function-name tenant-token-vendor --query 'Policy' --output text | python3 -m json.tool=== DDB encryption + PITR
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/2b7134b1-1cab-40dc-8cb9-285700ce9e49"
}
ENABLED
=== KMS alias points to non-aws-managed key
{
"Manager": "CUSTOMER",
"Arn": "arn:aws:kms:us-east-1:000000000000:key/2b7134b1-1cab-40dc-8cb9-285700ce9e49"
}
=== vendor exec role has NO managed policy attached
{
"AttachedPolicies": []
}
=== SSM parameters
/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 = 4ftewofzbt
/harbor/saas/api-url = http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token
=== APIGW stage access log + redeployed
{
"logArn": null,
"logFmt": null,
"deploymentId": "lfushxrgfb"
}
=== Lambda permission SourceArn pinning
{
"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:4ftewofzbt/*/GET/token"
}
}
}
]
}
[stdout]
=== DDB encryption + PITR
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/2b7134b1-1cab-40dc-8cb9-285700ce9e49"
}
ENABLED
=== KMS alias points to non-aws-managed key
{
"Manager": "CUSTOMER",
"Arn": "arn:aws:kms:us-east-1:000000000000:key/2b7134b1-1cab-40dc-8cb9-285700ce9e49"
}
=== vendor exec role has NO managed policy attached
{
"AttachedPolicies": []
}
=== SSM parameters
/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 = 4ftewofzbt
/harbor/saas/api-url = http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token
=== APIGW stage access log + redeployed
{
"logArn": null,
"logFmt": null,
"deploymentId": "lfushxrgfb"
}
=== Lambda permission SourceArn pinning
{
"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:4ftewofzbt/*/GET/token"
}
}
}
]
}Re-apply stage patch and verify
cat /app/policies/stage-access-log-patch.json
echo "---"
PATCH_FILE=$(mktemp)
sed "s|__APIGW_LOG_ARN__|arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api/prod|" /app/policies/stage-access-log-patch.json > "${PATCH_FILE}"
cat "${PATCH_FILE}"
echo "---"
aws apigateway update-stage \
--rest-api-id 4ftewofzbt \
--stage-name prod \
--patch-operations "file://${PATCH_FILE}" 2>&1 | tail -20
echo "---"
aws apigateway get-stage --rest-api-id 4ftewofzbt --stage-name prod --query 'accessLogSettings'[
{
"op": "replace",
"path": "/accessLogSettings/destinationArn",
"value": "__APIGW_LOG_ARN__"
},
{
"op": "replace",
"path": "/accessLogSettings/format",
"value": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\"}"
}
]
---
[
{
"op": "replace",
"path": "/accessLogSettings/destinationArn",
"value": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api/prod"
},
{
"op": "replace",
"path": "/accessLogSettings/format",
"value": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\"}"
}
]
---
{
"deploymentId": "lfushxrgfb",
"stageName": "prod",
"cacheClusterEnabled": false,
"cacheClusterStatus": "NOT_AVAILABLE",
"methodSettings": {},
"accessLogSettings": {
"format": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\"}",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api/prod"
},
"tracingEnabled": false,
"createdDate": "2026-04-29T17:44:27+00:00",
"lastUpdatedDate": "2026-04-29T17:55:03+00:00"
}
---
{
"format": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\"}",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api/prod"
}
[stdout]
[
{
"op": "replace",
"path": "/accessLogSettings/destinationArn",
"value": "__APIGW_LOG_ARN__"
},
{
"op": "replace",
"path": "/accessLogSettings/format",
"value": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\"}"
}
]
---
[
{
"op": "replace",
"path": "/accessLogSettings/destinationArn",
"value": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api/prod"
},
{
"op": "replace",
"path": "/accessLogSettings/format",
"value": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\"}"
}
]
---
{
"deploymentId": "lfushxrgfb",
"stageName": "prod",
"cacheClusterEnabled": false,
"cacheClusterStatus": "NOT_AVAILABLE",
"methodSettings": {},
"accessLogSettings": {
"format": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\"}",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api/prod"
},
"tracingEnabled": false,
"createdDate": "2026-04-29T17:44:27+00:00",
"lastUpdatedDate": "2026-04-29T17:55:03+00:00"
}
---
{
"format": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\"}",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api/prod"
}Redeploy after stage change
aws apigateway create-deployment --rest-api-id 4ftewofzbt --stage-name prod --query 'id' --output text echo "redeployed after stage change"
ssm6bwldko redeployed after stage change [stdout] ssm6bwldko redeployed after stage change
Final smoketest
bash /app/scripts/smoketest.sh 2>&1 | tail -25
API URL = http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token === A) bad tenant id -> HTTP 400 PASS bad tenant id rejected === B) good tenant id -> HTTP 200 with credential keys PASS good tenant id accepted PASS response carries all credential fields PASS tenant echoed back === C) credential body must NOT have appeared in lambda CloudWatch logs PASS no credential leakage in CloudWatch === D) auditor-style policy walk via IAM simulator PASS cross-tenant Query denied PASS password attribute explicitly denied PASS Scan denied === E) data-plane: real assumed-role tries cross-tenant Query (best-effort) INFO cross-tenant Query allowed by LocalStack data-plane (LocalStack does not always enforce dynamodb:LeadingKeys); auditor walk above is authoritative === F) data-plane: Scan (must deny by missing-action) PASS Scan denied at data-plane === summary: 9 pass / 0 fail [stdout] API URL = http://localstack:4566/restapis/4ftewofzbt/prod/_user_request_/token === A) bad tenant id -> HTTP 400 PASS bad tenant id rejected === B) good tenant id -> HTTP 200 with credential keys PASS good tenant id accepted PASS response carries all credential fields PASS tenant echoed back === C) credential body must NOT have appeared in lambda CloudWatch logs PASS no credential leakage in CloudWatch === D) auditor-style policy walk via IAM simulator PASS cross-tenant Query denied PASS password attribute explicitly denied PASS Scan denied === E) data-plane: real assumed-role tries cross-tenant Query (best-effort) INFO cross-tenant Query allowed by LocalStack data-plane (LocalStack does not always enforce dynamodb:LeadingKeys); auditor walk above is authoritative === F) data-plane: Scan (must deny by missing-action) PASS Scan denied at data-plane === summary: 9 pass / 0 fail
No 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 93ms
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.1, pluggy-1.6.0 -- /root/.cache/uv/archive-v0/D1ezsRBFfcd2lETm0Ydxe/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 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_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/D1ezsRBFfcd2lETm0Ydxe/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_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_15_lambda_source_duration_seconds_le_900
================== 1 failed, 34 passed, 60 warnings in 6.92s ===================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_a7aeb780360e4bc4. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_a7aeb780360e4bc4 · verifier authoritative; classifier explanatory.