tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.Inspect working directory
ls /app && echo "---" && ls /app/.. 2>/dev/null | head -20
--- app bin boot dev etc home installed-agent lib lib64 logs media mnt opt proc root run sbin srv sys tmp [stdout] --- app bin boot dev etc home installed-agent lib lib64 logs media mnt opt proc root run sbin srv sys tmp
Check available tools
which aws localstack awslocal terraform 2>&1; aws --version 2>&1; awslocal --version 2>&1 | head -3
/usr/local/bin/aws /opt/venv/bin/awslocal aws-cli/2.34.38 Python/3.14.4 Linux/6.1.158 exe/x86_64.ubuntu.24 aws-cli/2.34.38 Python/3.14.4 Linux/6.1.158 exe/x86_64.ubuntu.24 [stdout] /usr/local/bin/aws /opt/venv/bin/awslocal aws-cli/2.34.38 Python/3.14.4 Linux/6.1.158 exe/x86_64.ubuntu.24 aws-cli/2.34.38 Python/3.14.4 Linux/6.1.158 exe/x86_64.ubuntu.24
Check localstack status
awslocal sts get-caller-identity 2>&1; echo "---"; curl -s http://localhost:4566/_localstack/health 2>&1 | head -50
{
"UserId": "000000000000",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
---
[stdout]
{
"UserId": "000000000000",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
---Health and env
curl -sS http://localhost:4566/_localstack/health 2>&1; echo; echo "---ENV"; env | grep -iE "localstack|aws|iam|enforce" | head -20
curl: (7) Failed to connect to localhost port 4566 after 0 ms: Couldn't connect to server ---ENV AWS_DEFAULT_REGION=us-east-1 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test AWS_ACCESS_KEY_ID=test AWS_ENDPOINT_URL=http://localstack:4566 [stdout] curl: (7) Failed to connect to localhost port 4566 after 0 ms: Couldn't connect to server ---ENV AWS_DEFAULT_REGION=us-east-1 AWS_PAGER= AWS_SECRET_ACCESS_KEY=test AWS_ACCESS_KEY_ID=test AWS_ENDPOINT_URL=http://localstack:4566
localstack health
curl -sS http://localstack:4566/_localstack/health 2>&1 | python3 -m json.tool 2>&1 | head -50
{
"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",
[stdout]
{
"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",List /app
ls /app/ 2>&1; ls -la /app 2>&1
total 8 drwxr-xr-x 2 root root 4096 Apr 29 18:22 . drwxr-xr-x 1 root root 4096 Apr 29 18:22 .. [stdout] total 8 drwxr-xr-x 2 root root 4096 Apr 29 18:22 . drwxr-xr-x 1 root root 4096 Apr 29 18:22 ..
Create project layout
mkdir -p /app/lambda /app/policies && ls /app
lambda policies [stdout] lambda policies
import json
import os
import re
import boto3
from botocore.exceptions import ClientError
ALLOWED_TENANTS = {"acme", "globex", "globex-eu"}
TENANT_RE = re.compile(r"^[a-z][a-z0-9-]{0,30}$")
DATA_ROLE_ARN = os.environ["DATA_ROLE_ARN"]
MAX_DURATION = 900
_sts = boto3.client("sts")
def _resp(code, body):
return {
"statusCode": code,
"headers": {"content-type": "application/json"},
"body": json.dumps(body),
}
def handler(event, _context):
qs = (event or {}).get("queryStringParameters") or {}
tenant = (qs.get("tenant") or "").strip()
if not TENANT_RE.match(tenant) or tenant not in ALLOWED_TENANTS:
return _resp(400, {"error": "invalid tenant"})
try:
out = _sts.assume_role(
RoleArn=DATA_ROLE_ARN,
RoleSessionName=f"vendor-{tenant}",
DurationSeconds=MAX_DURATION,
Tags=[{"Key": "TenantID", "Value": tenant}],
TransitiveTagKeys=["TenantID"],
)
except ClientError:
return _resp(500, {"error": "assume_role_failed"})
creds = out["Credentials"]
return _resp(
200,
{
"tenant": tenant,
"AccessKeyId": creds["AccessKeyId"],
"SecretAccessKey": creds["SecretAccessKey"],
"SessionToken": creds["SessionToken"],
"Expiration": creds["Expiration"].isoformat(),
},
)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VendorMayAssumeWithTenantTag",
"Effect": "Allow",
"Principal": { "AWS": "__VENDOR_ROLE_ARN__" },
"Action": [
"sts:AssumeRole",
"sts:TagSession"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/TenantID": ["acme", "globex", "globex-eu"]
},
"ForAllValues:StringEquals": {
"aws:TagKeys": ["TenantID"]
},
"Null": {
"aws:RequestTag/TenantID": "false"
}
}
}
]
}
{
"query": "select:TodoWrite",
"max_results": 1
}
, output,
{"type": "tool_reference", "tool_name": "TodoWrite"}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TenantScopedRowsNoPasswordColumn",
"Effect": "Allow",
"Action": [
"dynamodb:Query",
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:BatchWriteItem",
"dynamodb:ConditionCheckItem",
"dynamodb:DescribeTable"
],
"Resource": "__TABLE_ARN__",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["${aws:PrincipalTag/TenantID}"],
"dynamodb:Attributes": ["TenantID", "OrderID", "amount"]
},
"StringEqualsIfExists": {
"dynamodb:Select": "SPECIFIC_ATTRIBUTES"
}
}
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AssumeDataRoleWithSessionTag",
"Effect": "Allow",
"Action": [
"sts:AssumeRole",
"sts:TagSession"
],
"Resource": "__DATA_ROLE_ARN__"
},
{
"Sid": "OwnLogGroupOnly",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:__REGION__:__ACCOUNT__:log-group:/aws/lambda/tenant-token-vendor:*"
}
]
}
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
REGION="${AWS_DEFAULT_REGION:-us-east-1}"
ACCOUNT="000000000000"
TABLE="SaasOrders"
KMS_ALIAS="alias/saas-orders-cmk"
DATA_ROLE="TenantDataRole"
VENDOR_ROLE="TenantTokenVendorRole"
LAMBDA_NAME="tenant-token-vendor"
API_NAME="harbor-saas-api"
STAGE="prod"
LAMBDA_LOG_GROUP="/aws/lambda/${LAMBDA_NAME}"
APIGW_LOG_GROUP="/aws/apigateway/${API_NAME}"
awsl() { awslocal "$@"; }
say() { printf '\n=== %s ===\n' "$*"; }
# ---------- KMS CMK ----------
say "kms cmk"
if ! awsl kms describe-key --key-id "$KMS_ALIAS" >/dev/null 2>&1; then
KEY_ID=$(awsl kms create-key --description "saas orders cmk" \
--key-usage ENCRYPT_DECRYPT --key-spec SYMMETRIC_DEFAULT \
--query 'KeyMetadata.KeyId' --output text)
awsl kms create-alias --alias-name "$KMS_ALIAS" --target-key-id "$KEY_ID"
else
KEY_ID=$(awsl kms describe-key --key-id "$KMS_ALIAS" --query 'KeyMetadata.KeyId' --output text)
fi
echo "cmk key id: $KEY_ID"
# ---------- DynamoDB table ----------
say "ddb table"
if ! awsl dynamodb describe-table --table-name "$TABLE" >/dev/null 2>&1; then
awsl 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=${KMS_ALIAS}" >/dev/null
awsl dynamodb wait table-exists --table-name "$TABLE"
fi
TABLE_ARN=$(awsl dynamodb describe-table --table-name "$TABLE" --query 'Table.TableArn' --output text)
echo "table arn: $TABLE_ARN"
awsl dynamodb update-continuous-backups \
--table-name "$TABLE" \
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true >/dev/null || true
# ---------- Seed 6 items: 2 each for acme, globex, globex-eu ----------
say "seeding rows"
seed() {
local tenant="$1" order="$2" amount="$3" pw="$4"
awsl dynamodb put-item --table-name "$TABLE" --item "$(cat <<EOF
{
"TenantID": {"S": "${tenant}"},
"OrderID": {"S": "${order}"},
"amount": {"N": "${amount}"},
"password": {"S": "${pw}"}
}
EOF
)" >/dev/null
}
seed acme o-1001 1299 "acme-secret-1"
seed acme o-1002 4250 "acme-secret-2"
seed globex o-2001 599 "globex-secret-1"
seed globex o-2002 8800 "globex-secret-2"
seed globex-eu o-3001 120 "globex-eu-secret-1"
seed globex-eu o-3002 540 "globex-eu-secret-2"
echo "seeded 6 rows"
# ---------- Vendor role (must exist before data role trust references it) ----------
say "vendor role"
if ! awsl iam get-role --role-name "$VENDOR_ROLE" >/dev/null 2>&1; then
awsl iam create-role --role-name "$VENDOR_ROLE" \
--assume-role-policy-document "file://${ROOT}/policies/vendor-role-trust.json" >/dev/null
fi
VENDOR_ROLE_ARN=$(awsl iam get-role --role-name "$VENDOR_ROLE" --query 'Role.Arn' --output text)
echo "vendor role arn: $VENDOR_ROLE_ARN"
# ---------- Data role ----------
say "data role"
DATA_TRUST=$(mktemp)
sed "s|__VENDOR_ROLE_ARN__|${VENDOR_ROLE_ARN}|g" \
"${ROOT}/policies/data-role-trust.json" > "$DATA_TRUST"
if ! awsl iam get-role --role-name "$DATA_ROLE" >/dev/null 2>&1; then
awsl iam create-role --role-name "$DATA_ROLE" \
--assume-role-policy-document "file://${DATA_TRUST}" >/dev/null
else
awsl iam update-assume-role-policy --role-name "$DATA_ROLE" \
--policy-document "file://${DATA_TRUST}" >/dev/null
fi
DATA_ROLE_ARN=$(awsl iam get-role --role-name "$DATA_ROLE" --query 'Role.Arn' --output text)
echo "data role arn: $DATA_ROLE_ARN"
DATA_IDENT=$(mktemp)
sed "s|__TABLE_ARN__|${TABLE_ARN}|g" \
"${ROOT}/policies/data-role-identity.json" > "$DATA_IDENT"
awsl iam put-role-policy --role-name "$DATA_ROLE" \
--policy-name TenantDataAccess \
--policy-document "file://${DATA_IDENT}" >/dev/null
# ---------- Vendor role inline permissions (now that data role exists) ----------
say "vendor role inline permissions"
VENDOR_PERMS=$(mktemp)
sed -e "s|__DATA_ROLE_ARN__|${DATA_ROLE_ARN}|g" \
-e "s|__REGION__|${REGION}|g" \
-e "s|__ACCOUNT__|${ACCOUNT}|g" \
"${ROOT}/policies/vendor-role-permissions.json" > "$VENDOR_PERMS"
awsl iam put-role-policy --role-name "$VENDOR_ROLE" \
--policy-name VendorAssumeAndOwnLogs \
--policy-document "file://${VENDOR_PERMS}" >/dev/null
# ---------- Lambda log group precreated (so vendor inline grant fits) ----------
say "lambda log group"
awsl logs create-log-group --log-group-name "$LAMBDA_LOG_GROUP" >/dev/null 2>&1 || true
# ---------- Package & deploy lambda ----------
say "package lambda"
PKG=$(mktemp -d)
cp "${ROOT}/lambda/tenant_token_vendor.py" "${PKG}/"
( cd "$PKG" && zip -q -r lambda.zip tenant_token_vendor.py )
LAMBDA_ZIP="${PKG}/lambda.zip"
if awsl lambda get-function --function-name "$LAMBDA_NAME" >/dev/null 2>&1; then
awsl lambda update-function-code --function-name "$LAMBDA_NAME" \
--zip-file "fileb://${LAMBDA_ZIP}" >/dev/null
awsl lambda wait function-updated --function-name "$LAMBDA_NAME"
awsl lambda update-function-configuration --function-name "$LAMBDA_NAME" \
--role "$VENDOR_ROLE_ARN" \
--environment "Variables={DATA_ROLE_ARN=${DATA_ROLE_ARN}}" >/dev/null
awsl lambda wait function-updated --function-name "$LAMBDA_NAME"
else
awsl lambda create-function --function-name "$LAMBDA_NAME" \
--runtime python3.11 \
--handler tenant_token_vendor.handler \
--role "$VENDOR_ROLE_ARN" \
--timeout 10 \
--environment "Variables={DATA_ROLE_ARN=${DATA_ROLE_ARN}}" \
--zip-file "fileb://${LAMBDA_ZIP}" >/dev/null
awsl lambda wait function-active --function-name "$LAMBDA_NAME"
fi
LAMBDA_ARN=$(awsl lambda get-function --function-name "$LAMBDA_NAME" \
--query 'Configuration.FunctionArn' --output text)
echo "lambda arn: $LAMBDA_ARN"
# ---------- API Gateway REST v1 ----------
say "api gateway"
API_ID=$(awsl apigateway get-rest-apis --query "items[?name=='${API_NAME}'].id | [0]" --output text)
if [ -z "$API_ID" ] || [ "$API_ID" = "None" ]; then
API_ID=$(awsl apigateway create-rest-api --name "$API_NAME" \
--endpoint-configuration types=REGIONAL \
--query 'id' --output text)
fi
echo "api id: $API_ID"
ROOT_ID=$(awsl apigateway get-resources --rest-api-id "$API_ID" \
--query "items[?path=='/'].id | [0]" --output text)
TOKEN_ID=$(awsl apigateway get-resources --rest-api-id "$API_ID" \
--query "items[?path=='/token'].id | [0]" --output text)
if [ -z "$TOKEN_ID" ] || [ "$TOKEN_ID" = "None" ]; then
TOKEN_ID=$(awsl apigateway create-resource --rest-api-id "$API_ID" \
--parent-id "$ROOT_ID" --path-part token \
--query 'id' --output text)
fi
echo "token resource id: $TOKEN_ID"
awsl apigateway put-method --rest-api-id "$API_ID" --resource-id "$TOKEN_ID" \
--http-method GET --authorization-type NONE >/dev/null 2>&1 || true
LAMBDA_URI="arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/${LAMBDA_ARN}/invocations"
awsl apigateway put-integration --rest-api-id "$API_ID" --resource-id "$TOKEN_ID" \
--http-method GET --type AWS_PROXY \
--integration-http-method POST \
--uri "$LAMBDA_URI" >/dev/null
# ---------- Lambda invoke permission pinned to this api's resource arn ----------
say "lambda invoke permission (SourceArn pinned)"
SOURCE_ARN="arn:aws:execute-api:${REGION}:${ACCOUNT}:${API_ID}/*/GET/token"
awsl lambda remove-permission --function-name "$LAMBDA_NAME" \
--statement-id apigw-invoke >/dev/null 2>&1 || true
awsl lambda add-permission --function-name "$LAMBDA_NAME" \
--statement-id apigw-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "$SOURCE_ARN" >/dev/null
echo "source arn: $SOURCE_ARN"
# ---------- API GW access log group + stage with access logging ----------
say "apigw access logs"
awsl logs create-log-group --log-group-name "$APIGW_LOG_GROUP" >/dev/null 2>&1 || true
APIGW_LOG_ARN="arn:aws:logs:${REGION}:${ACCOUNT}:log-group:${APIGW_LOG_GROUP}"
# Deploy first (creates stage if absent)
awsl apigateway create-deployment --rest-api-id "$API_ID" --stage-name "$STAGE" >/dev/null
# Attach access log settings to the stage. Format = compact JSON of $context fields, no body.
ACCESS_FORMAT='{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","method":"$context.httpMethod","path":"$context.path","status":"$context.status","latency":"$context.responseLatency"}'
awsl apigateway update-stage --rest-api-id "$API_ID" --stage-name "$STAGE" \
--patch-operations \
"op=replace,path=/accessLogSettings/destinationArn,value=${APIGW_LOG_ARN}" \
"op=replace,path=/accessLogSettings/format,value=${ACCESS_FORMAT}" >/dev/null
# Redeploy after every wiring change
awsl apigateway create-deployment --rest-api-id "$API_ID" --stage-name "$STAGE" \
--description "post-wiring redeploy" >/dev/null
INVOKE_URL="http://localhost:4566/restapis/${API_ID}/${STAGE}/_user_request_"
echo "invoke base: $INVOKE_URL"
# ---------- SSM pointers ----------
say "ssm pointers"
put_param() {
awsl ssm put-parameter --name "$1" --type String --value "$2" --overwrite >/dev/null
}
put_param /harbor/saas/table-name "$TABLE"
put_param /harbor/saas/role-arn "$DATA_ROLE_ARN"
put_param /harbor/saas/vendor-role-arn "$VENDOR_ROLE_ARN"
put_param /harbor/saas/lambda-arn "$LAMBDA_ARN"
put_param /harbor/saas/api-id "$API_ID"
put_param /harbor/saas/api-url "$INVOKE_URL"
say "done"
echo "table: $TABLE"
echo "table arn: $TABLE_ARN"
echo "kms: $KMS_ALIAS ($KEY_ID)"
echo "vendor role: $VENDOR_ROLE_ARN"
echo "data role: $DATA_ROLE_ARN"
echo "lambda: $LAMBDA_ARN"
echo "api id: $API_ID"
echo "stage: $STAGE"
echo "invoke url: $INVOKE_URL"
echo "source arn pin: $SOURCE_ARN"
Make executable and verify zip
chmod +x /app/bootstrap.sh && which zip
/usr/bin/zip [stdout] /usr/bin/zip
Run bootstrap
bash bootstrap.sh 2>&1 | tail -120
=== kms cmk ===
cmk key id: d093cad2-8b1f-43b4-aca0-d3e3ac195b31
=== ddb table ===
table arn: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders
=== seeding rows ===
seeded 6 rows
=== vendor role ===
vendor role arn: arn:aws:iam::000000000000:role/TenantTokenVendorRole
=== data role ===
data role arn: arn:aws:iam::000000000000:role/TenantDataRole
=== vendor role inline permissions ===
=== lambda log group ===
=== package lambda ===
lambda arn: arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor
=== api gateway ===
api id: gnjfnp04kp
token resource id: uecabycziv
=== lambda invoke permission (SourceArn pinned) ===
source arn: arn:aws:execute-api:us-east-1:000000000000:gnjfnp04kp/*/GET/token
=== apigw access logs ===
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","latency":"$context.responseLatency"}
^
[stdout]
=== kms cmk ===
cmk key id: d093cad2-8b1f-43b4-aca0-d3e3ac195b31
=== ddb table ===
table arn: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders
=== seeding rows ===
seeded 6 rows
=== vendor role ===
vendor role arn: arn:aws:iam::000000000000:role/TenantTokenVendorRole
=== data role ===
data role arn: arn:aws:iam::000000000000:role/TenantDataRole
=== vendor role inline permissions ===
=== lambda log group ===
=== package lambda ===
lambda arn: arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor
=== api gateway ===
api id: gnjfnp04kp
token resource id: uecabycziv
=== lambda invoke permission (SourceArn pinned) ===
source arn: arn:aws:execute-api:us-east-1:000000000000:gnjfnp04kp/*/GET/token
=== apigw access logs ===
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","latency":"$context.responseLatency"}
^/app/bootstrap.sh
# Attach access log settings to the stage. Format = compact JSON of $context fields, no body.
ACCESS_FORMAT='{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","method":"$context.httpMethod","path":"$context.path","status":"$context.status","latency":"$context.responseLatency"}'
awsl apigateway update-stage --rest-api-id "$API_ID" --stage-name "$STAGE" \
--patch-operations \
"op=replace,path=/accessLogSettings/destinationArn,value=${APIGW_LOG_ARN}" \
"op=replace,path=/accessLogSettings/format,value=${ACCESS_FORMAT}" >/dev/null# Attach access log settings to the stage. Format = compact JSON of $context fields, no body.
ACCESS_FORMAT='{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","method":"$context.httpMethod","path":"$context.path","status":"$context.status","latency":"$context.responseLatency"}'
PATCH_FILE=$(mktemp)
python3 - "$APIGW_LOG_ARN" "$ACCESS_FORMAT" > "$PATCH_FILE" <<'PY'
import json, sys
arn, fmt = sys.argv[1], sys.argv[2]
print(json.dumps([
{"op": "replace", "path": "/accessLogSettings/destinationArn", "value": arn},
{"op": "replace", "path": "/accessLogSettings/format", "value": fmt},
]))
PY
awsl apigateway update-stage --rest-api-id "$API_ID" --stage-name "$STAGE" \
--patch-operations "file://${PATCH_FILE}" >/dev/nullRe-run bootstrap
bash /app/bootstrap.sh 2>&1 | tail -60
=== kms cmk === cmk key id: d093cad2-8b1f-43b4-aca0-d3e3ac195b31 === ddb table === table arn: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders === seeding rows === seeded 6 rows === vendor role === vendor role arn: arn:aws:iam::000000000000:role/TenantTokenVendorRole === data role === data role arn: arn:aws:iam::000000000000:role/TenantDataRole === vendor role inline permissions === === lambda log group === === package lambda === lambda arn: arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor === api gateway === api id: gnjfnp04kp token resource id: uecabycziv === lambda invoke permission (SourceArn pinned) === source arn: arn:aws:execute-api:us-east-1:000000000000:gnjfnp04kp/*/GET/token === apigw access logs === invoke base: http://localhost:4566/restapis/gnjfnp04kp/prod/_user_request_ === ssm pointers === === done === table: SaasOrders table arn: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders kms: alias/saas-orders-cmk (d093cad2-8b1f-43b4-aca0-d3e3ac195b31) vendor role: arn:aws:iam::000000000000:role/TenantTokenVendorRole data role: arn:aws:iam::000000000000:role/TenantDataRole lambda: arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor api id: gnjfnp04kp stage: prod invoke url: http://localhost:4566/restapis/gnjfnp04kp/prod/_user_request_ source arn pin: arn:aws:execute-api:us-east-1:000000000000:gnjfnp04kp/*/GET/token [stdout] === kms cmk === cmk key id: d093cad2-8b1f-43b4-aca0-d3e3ac195b31 === ddb table === table arn: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders === seeding rows === seeded 6 rows === vendor role === vendor role arn: arn:aws:iam::000000000000:role/TenantTokenVendorRole === data role === data role arn: arn:aws:iam::000000000000:role/TenantDataRole === vendor role inline permissions === === lambda log group === === package lambda === lambda arn: arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor === api gateway === api id: gnjfnp04kp token resource id: uecabycziv === lambda invoke permission (SourceArn pinned) === source arn: arn:aws:execute-api:us-east-1:000000000000:gnjfnp04kp/*/GET/token === apigw access logs === invoke base: http://localhost:4566/restapis/gnjfnp04kp/prod/_user_request_ === ssm pointers === === done === table: SaasOrders table arn: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders kms: alias/saas-orders-cmk (d093cad2-8b1f-43b4-aca0-d3e3ac195b31) vendor role: arn:aws:iam::000000000000:role/TenantTokenVendorRole data role: arn:aws:iam::000000000000:role/TenantDataRole lambda: arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor api id: gnjfnp04kp stage: prod invoke url: http://localhost:4566/restapis/gnjfnp04kp/prod/_user_request_ source arn pin: arn:aws:execute-api:us-east-1:000000000000:gnjfnp04kp/*/GET/token
Smoke test GET /token?tenant=acme
API_ID=$(awslocal ssm get-parameter --name /harbor/saas/api-id --query 'Parameter.Value' --output text)
URL="http://localstack:4566/restapis/${API_ID}/prod/_user_request_/token?tenant=acme"
echo "GET $URL"
curl -sS -i "$URL" 2>&1 | head -40GET http://localstack:4566/restapis/gnjfnp04kp/prod/_user_request_/token?tenant=acme
HTTP/1.1 200 OK
Server: TwistedWeb/25.5.0
Date: Wed, 29 Apr 2026 18:29:04 GMT
content-type: application/json
Connection: keep-alive
Content-Length: 546
x-amzn-RequestId: 056f7b86-a942-409f-a97d-3ae832c9a017
x-amz-apigw-id: 8b192274=
X-Amzn-Trace-Id: Root=1-69f24df0-0bfd1dffbbe48e2ad9d2ccb2;Parent=add7ad91a6f8ac8e;Sampled=0
x-localstack: true
{"tenant": "acme", "AccessKeyId": "LSIAQAAAAAAADH6SI3YG", "SecretAccessKey": "5msZaKdX+YfsyUbpuavuzy3nTu74Ee+KnWMFaoDm", "SessionToken": "FQoGZXIvYXdzE1PEaysFUe0OXGsYRcfailxfkZcGoZeZefzndLd7CZ7ng8MyHpllbD9v/W2TRTZBGNvf0oGFagFlvICelMhd0qq4kKtVg2j4j0Gm89vXTPVunHppaNsk/82hehBUXrWOS54pwdoTPq7odS41QVNrXMvdA=2+K7Tg0EZRtUI5eDubk0Ct=t3xr5xUJgFToi4q9MlAVPRoKqd1zQXMIKmY5Mn/w2FbCS68kgNkoMaNRmvXF2kMvw2KjOHGd=01oDseZ5ooN7wnaw+gE6ALWxPqwTazmBaGdWEeYInQ=CuZeKfpVH9WahSWfPvXdms4i56qNKAAwYr+HhHPyumZMFHzoS1N", "Expiration": "2026-04-29T18:44:05.073456+00:00"}
[stdout]
GET http://localstack:4566/restapis/gnjfnp04kp/prod/_user_request_/token?tenant=acme
HTTP/1.1 200 OK
Server: TwistedWeb/25.5.0
Date: Wed, 29 Apr 2026 18:29:04 GMT
content-type: application/json
Connection: keep-alive
Content-Length: 546
x-amzn-RequestId: 056f7b86-a942-409f-a97d-3ae832c9a017
x-amz-apigw-id: 8b192274=
X-Amzn-Trace-Id: Root=1-69f24df0-0bfd1dffbbe48e2ad9d2ccb2;Parent=add7ad91a6f8ac8e;Sampled=0
x-localstack: true
{"tenant": "acme", "AccessKeyId": "LSIAQAAAAAAADH6SI3YG", "SecretAccessKey": "5msZaKdX+YfsyUbpuavuzy3nTu74Ee+KnWMFaoDm", "SessionToken": "FQoGZXIvYXdzE1PEaysFUe0OXGsYRcfailxfkZcGoZeZefzndLd7CZ7ng8MyHpllbD9v/W2TRTZBGNvf0oGFagFlvICelMhd0qq4kKtVg2j4j0Gm89vXTPVunHppaNsk/82hehBUXrWOS54pwdoTPq7odS41QVNrXMvdA=2+K7Tg0EZRtUI5eDubk0Ct=t3xr5xUJgFToi4q9MlAVPRoKqd1zQXMIKmY5Mn/w2FbCS68kgNkoMaNRmvXF2kMvw2KjOHGd=01oDseZ5ooN7wnaw+gE6ALWxPqwTazmBaGdWEeYInQ=CuZeKfpVH9WahSWfPvXdms4i56qNKAAwYr+HhHPyumZMFHzoS1N", "Expiration": "2026-04-29T18:44:05.073456+00:00"}400 on bad tenants
API_ID=$(awslocal ssm get-parameter --name /harbor/saas/api-id --query 'Parameter.Value' --output text)
BASE="http://localstack:4566/restapis/${API_ID}/prod/_user_request_"
echo "--- 400 path: bogus tenant"
curl -sS -o /tmp/r1 -w "HTTP %{http_code}\n" "${BASE}/token?tenant=evilcorp"
cat /tmp/r1; echo
curl -sS -o /tmp/r2 -w "HTTP %{http_code}\n" "${BASE}/token?tenant=ACME"
cat /tmp/r2; echo
curl -sS -o /tmp/r3 -w "HTTP %{http_code}\n" "${BASE}/token?tenant=globex_eu"
cat /tmp/r3; echo--- 400 path: bogus tenant
HTTP 400
{"error": "invalid tenant"}
HTTP 400
{"error": "invalid tenant"}
HTTP 400
{"error": "invalid tenant"}
[stdout]
--- 400 path: bogus tenant
HTTP 400
{"error": "invalid tenant"}
HTTP 400
{"error": "invalid tenant"}
HTTP 400
{"error": "invalid tenant"}Verify ABAC enforcement under temp creds
API_ID=$(awslocal ssm get-parameter --name /harbor/saas/api-id --query 'Parameter.Value' --output text)
BASE="http://localstack:4566/restapis/${API_ID}/prod/_user_request_"
# Get acme creds via the vendor
curl -sS "${BASE}/token?tenant=acme" > /tmp/acme.json
python3 - <<'PY'
import json, os, subprocess, sys
c = json.load(open('/tmp/acme.json'))
env = os.environ.copy()
env.update({
"AWS_ACCESS_KEY_ID": c["AccessKeyId"],
"AWS_SECRET_ACCESS_KEY": c["SecretAccessKey"],
"AWS_SESSION_TOKEN": c["SessionToken"],
})
def run(args, **kw):
r = subprocess.run(["awslocal"]+args, env=env, capture_output=True, text=True)
return r.returncode, r.stdout, r.stderr
print("--- own-tenant query, projection excludes password (allowed) ---")
rc,o,e = run(["dynamodb","query","--table-name","SaasOrders",
"--key-condition-expression","TenantID = :t",
"--expression-attribute-values",'{":t":{"S":"acme"}}',
"--projection-expression","TenantID,OrderID,amount",
"--select","SPECIFIC_ATTRIBUTES"])
print("rc=",rc); print(o); print("STDERR:",e[:400])
print("--- cross-tenant query (should be DENIED) ---")
rc,o,e = run(["dynamodb","query","--table-name","SaasOrders",
"--key-condition-expression","TenantID = :t",
"--expression-attribute-values",'{":t":{"S":"globex"}}',
"--projection-expression","TenantID,OrderID,amount",
"--select","SPECIFIC_ATTRIBUTES"])
print("rc=",rc); print(o[:200]); print("STDERR:",e[:400])
print("--- own-tenant query asking for password attribute (should be DENIED) ---")
rc,o,e = run(["dynamodb","query","--table-name","SaasOrders",
"--key-condition-expression","TenantID = :t",
"--expression-attribute-values",'{":t":{"S":"acme"}}',
"--projection-expression","TenantID,OrderID,#p",
"--expression-attribute-names",'{"#p":"password"}',
"--select","SPECIFIC_ATTRIBUTES"])
print("rc=",rc); print(o[:200]); print("STDERR:",e[:400])
print("--- Scan should be DENIED (action not granted) ---")
rc,o,e = run(["dynamodb","scan","--table-name","SaasOrders","--limit","1"])
print("rc=",rc); print(o[:200]); print("STDERR:",e[:400])
PY--- own-tenant query, projection excludes password (allowed) ---
rc= 0
{
"Items": [
{
"OrderID": {
"S": "o-1001"
},
"TenantID": {
"S": "acme"
},
"amount": {
"N": "1299"
}
},
{
"OrderID": {
"S": "o-1002"
},
"TenantID": {
"S": "acme"
},
"amount": {
"N": "4250"
}
}
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
STDERR:
--- cross-tenant query (should be DENIED) ---
rc= 0
{
"Items": [
{
"OrderID": {
"S": "o-2001"
},
"TenantID": {
"S": "globex"
},
"amount": {
STDERR:
--- own-tenant query asking for password attribute (should be DENIED) ---
rc= 0
{
"Items": [
{
"OrderID": {
"S": "o-1001"
},
"TenantID": {
"S": "acme"
},
"password": {
STDERR:
--- Scan should be DENIED (action not granted) ---
rc= 254
STDERR:
aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Scan operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/vendor-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
[stdout]
--- own-tenant query, projection excludes password (allowed) ---
rc= 0
{
"Items": [
{
"OrderID": {
"S": "o-1001"
},
"TenantID": {
"S": "acme"
},
"amount": {
"N": "1299"
}
},
{
"OrderID": {
"S": "o-1002"
},
"TenantID": {
"S": "acme"
},
"amount": {
"N": "4250"
}
}
],
"Count": 2,
"ScannedCount": 2,
"ConsumedCapacity": null
}
STDERR:
--- cross-tenant query (should be DENIED) ---
rc= 0
{
"Items": [
{
"OrderID": {
"S": "o-2001"
},
"TenantID": {
"S": "globex"
},
"amount": {
STDERR:
--- own-tenant query asking for password attribute (should be DENIED) ---
rc= 0
{
"Items": [
{
"OrderID": {
"S": "o-1001"
},
"TenantID": {
"S": "acme"
},
"password": {
STDERR:
--- Scan should be DENIED (action not granted) ---
rc= 254
STDERR:
aws: [ERROR]: An error occurred (AccessDeniedException) when calling the Scan operation: User: arn:aws:sts::000000000000:assumed-role/TenantDataRole/vendor-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 actionInspect IAM enforcement state and stored policy
echo "--- env on localstack (best effort) ---"; env | grep -i enforce echo "--- localstack config endpoint ---" curl -sS http://localstack:4566/_localstack/config 2>/dev/null | python3 -c 'import sys,json; d=json.load(sys.stdin); [print(k,"=",v) for k,v in sorted(d.items()) if "iam" in k.lower() or "enforce" in k.lower()]' 2>&1 | head -20 echo "--- data role identity policy as stored ---" awslocal iam get-role-policy --role-name TenantDataRole --policy-name TenantDataAccess --output json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(json.dumps(d["PolicyDocument"], indent=2))'
--- env on localstack (best effort) ---
--- localstack config endpoint ---
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)
--- data role identity policy as stored ---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TenantScopedRowsNoPasswordColumn",
"Effect": "Allow",
"Action": [
"dynamodb:Query",
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:BatchWriteItem",
"dynamodb:ConditionCheckItem",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
],
"dynamodb:Attributes": [
"TenantID",
"OrderID",
"amount"
]
},
"StringEqualsIfExists": {
"dynamodb:Select": "SPECIFIC_ATTRIBUTES"
}
}
}
]
}
[stdout]
--- env on localstack (best effort) ---
--- localstack config endpoint ---
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)
--- data role identity policy as stored ---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TenantScopedRowsNoPasswordColumn",
"Effect": "Allow",
"Action": [
"dynamodb:Query",
"dynamodb:GetItem",
"dynamodb:BatchGetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:DeleteItem",
"dynamodb:BatchWriteItem",
"dynamodb:ConditionCheckItem",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": [
"${aws:PrincipalTag/TenantID}"
],
"dynamodb:Attributes": [
"TenantID",
"OrderID",
"amount"
]
},
"StringEqualsIfExists": {
"dynamodb:Select": "SPECIFIC_ATTRIBUTES"
}
}
}
]
}Verify trust + assume traps fire
echo "--- data role trust policy ---" awslocal iam get-role --role-name TenantDataRole --query 'Role.AssumeRolePolicyDocument' --output json | python3 -m json.tool echo echo "--- vendor role inline permissions ---" awslocal iam get-role-policy --role-name TenantTokenVendorRole --policy-name VendorAssumeAndOwnLogs --output json | python3 -c 'import sys,json; d=json.load(sys.stdin); print(json.dumps(d["PolicyDocument"], indent=2))' echo echo "--- vendor role trust ---" awslocal iam get-role --role-name TenantTokenVendorRole --query 'Role.AssumeRolePolicyDocument' --output json | python3 -m json.tool echo echo "--- vendor: managed policies attached (must NOT include AWSLambdaBasicExecutionRole) ---" awslocal iam list-attached-role-policies --role-name TenantTokenVendorRole echo echo "--- AssumeRole call where --tags is missing (should be DENIED by Null condition) ---" awslocal sts assume-role --role-arn arn:aws:iam::000000000000:role/TenantDataRole \ --role-session-name no-tag-test --duration-seconds 900 2>&1 | tail -3 echo echo "--- AssumeRole with disallowed tenant value (should be DENIED by allowlist) ---" awslocal sts assume-role --role-arn arn:aws:iam::000000000000:role/TenantDataRole \ --role-session-name evil-test --duration-seconds 900 \ --tags Key=TenantID,Value=evilcorp 2>&1 | tail -3
--- data role trust policy ---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VendorMayAssumeWithTenantTag",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/TenantTokenVendorRole"
},
"Action": [
"sts:AssumeRole",
"sts:TagSession"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/TenantID": [
"acme",
"globex",
"globex-eu"
]
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"TenantID"
]
},
"Null": {
"aws:RequestTag/TenantID": "false"
}
}
}
]
}
--- vendor role inline permissions ---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AssumeDataRoleWithSessionTag",
"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:*"
}
]
}
--- vendor role trust ---
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
--- vendor: managed policies attached (must NOT include AWSLambdaBasicExecutionRole) ---
{
"AttachedPolicies": []
}
--- AssumeRole call where --tags is missing (should be DENIED by Null condition) ---
"Arn": "arn:aws:sts::000000000000:assumed-role/TenantDataRole/no-tag-test"
}
}
--- AssumeRole with disallowed tenant value (should be DENIED by allowlist) ---
"Arn": "arn:aws:sts::000000000000:assumed-role/TenantDataRole/evil-test"
}
}
[stdout]
--- data role trust policy ---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VendorMayAssumeWithTenantTag",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::000000000000:role/TenantTokenVendorRole"
},
"Action": [
"sts:AssumeRole",
"sts:TagSession"
],
"Condition": {
"StringEquals": {
"aws:RequestTag/TenantID": [
"acme",
"globex",
"globex-eu"
]
},
"ForAllValues:StringEquals": {
"aws:TagKeys": [
"TenantID"
]
},
"Null": {
"aws:RequestTag/TenantID": "false"
}
}
}
]
}
--- vendor role inline permissions ---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AssumeDataRoleWithSessionTag",
"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:*"
}
]
}
--- vendor role trust ---
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
--- vendor: managed policies attached (must NOT include AWSLambdaBasicExecutionRole) ---
{
"AttachedPolicies": []
}
--- AssumeRole call where --tags is missing (should be DENIED by Null condition) ---
"Arn": "arn:aws:sts::000000000000:assumed-role/TenantDataRole/no-tag-test"
}
}
--- AssumeRole with disallowed tenant value (should be DENIED by allowlist) ---
"Arn": "arn:aws:sts::000000000000:assumed-role/TenantDataRole/evil-test"
}
}Static facts the auditor walks
echo "--- PITR ---"
awslocal dynamodb describe-continuous-backups --table-name SaasOrders \
--query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription' --output json
echo "--- table SSE ---"
awslocal dynamodb describe-table --table-name SaasOrders \
--query 'Table.SSEDescription' --output json
echo "--- KMS alias ---"
awslocal kms list-aliases --query "Aliases[?AliasName=='alias/saas-orders-cmk']" --output json
echo "--- seed counts ---"
for t in acme globex globex-eu; do
c=$(awslocal dynamodb query --table-name SaasOrders \
--key-condition-expression "TenantID = :t" \
--expression-attribute-values "{\":t\":{\"S\":\"${t}\"}}" \
--select COUNT --query 'Count' --output text)
echo "$t: $c"
done
echo "--- SSM pointers ---"
awslocal ssm get-parameters-by-path --path /harbor/saas/ --query 'Parameters[].[Name,Value]' --output table
echo "--- API stage access log destination ---"
API_ID=$(awslocal ssm get-parameter --name /harbor/saas/api-id --query 'Parameter.Value' --output text)
awslocal apigateway get-stage --rest-api-id "$API_ID" --stage-name prod \
--query 'accessLogSettings' --output json
echo "--- Lambda invoke permission policy (SourceArn pin) ---"
awslocal lambda get-policy --function-name tenant-token-vendor \
--query 'Policy' --output text | python3 -c 'import sys,json; p=json.loads(sys.stdin.read()); print(json.dumps(p, indent=2))'--- PITR ---
{
"PointInTimeRecoveryStatus": "ENABLED"
}
--- table SSE ---
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/alias/saas-orders-cmk"
}
--- KMS alias ---
[
{
"AliasName": "alias/saas-orders-cmk",
"AliasArn": "arn:aws:kms:us-east-1:000000000000:alias/saas-orders-cmk",
"TargetKeyId": "d093cad2-8b1f-43b4-aca0-d3e3ac195b31",
"CreationDate": "2026-04-29T18:27:03.389104+00:00"
}
]
--- seed counts ---
acme: 2
globex: 2
globex-eu: 2
--- SSM pointers ---
--------------------------------------------------------------------------------------------------------
| GetParametersByPath |
+-------------------------------+----------------------------------------------------------------------+
| /harbor/saas/table-name | SaasOrders |
| /harbor/saas/role-arn | arn:aws:iam::000000000000:role/TenantDataRole |
| /harbor/saas/vendor-role-arn | arn:aws:iam::000000000000:role/TenantTokenVendorRole |
| /harbor/saas/lambda-arn | arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor |
| /harbor/saas/api-id | gnjfnp04kp |
| /harbor/saas/api-url | http://localhost:4566/restapis/gnjfnp04kp/prod/_user_request_ |
+-------------------------------+----------------------------------------------------------------------+
--- API stage access log destination ---
null
--- Lambda invoke permission policy (SourceArn pin) ---
{
"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:gnjfnp04kp/*/GET/token"
}
}
}
]
}
[stdout]
--- PITR ---
{
"PointInTimeRecoveryStatus": "ENABLED"
}
--- table SSE ---
{
"Status": "ENABLED",
"SSEType": "KMS",
"KMSMasterKeyArn": "arn:aws:kms:us-east-1:000000000000:key/alias/saas-orders-cmk"
}
--- KMS alias ---
[
{
"AliasName": "alias/saas-orders-cmk",
"AliasArn": "arn:aws:kms:us-east-1:000000000000:alias/saas-orders-cmk",
"TargetKeyId": "d093cad2-8b1f-43b4-aca0-d3e3ac195b31",
"CreationDate": "2026-04-29T18:27:03.389104+00:00"
}
]
--- seed counts ---
acme: 2
globex: 2
globex-eu: 2
--- SSM pointers ---
--------------------------------------------------------------------------------------------------------
| GetParametersByPath |
+-------------------------------+----------------------------------------------------------------------+
| /harbor/saas/table-name | SaasOrders |
| /harbor/saas/role-arn | arn:aws:iam::000000000000:role/TenantDataRole |
| /harbor/saas/vendor-role-arn | arn:aws:iam::000000000000:role/TenantTokenVendorRole |
| /harbor/saas/lambda-arn | arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor |
| /harbor/saas/api-id | gnjfnp04kp |
| /harbor/saas/api-url | http://localhost:4566/restapis/gnjfnp04kp/prod/_user_request_ |
+-------------------------------+----------------------------------------------------------------------+
--- API stage access log destination ---
null
--- Lambda invoke permission policy (SourceArn pin) ---
{
"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:gnjfnp04kp/*/GET/token"
}
}
}
]
}Re-apply access log settings and inspect
API_ID=$(awslocal ssm get-parameter --name /harbor/saas/api-id --query 'Parameter.Value' --output text)
LOG_ARN="arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api"
FMT='{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","method":"$context.httpMethod","path":"$context.path","status":"$context.status","latency":"$context.responseLatency"}'
PATCH=$(mktemp)
python3 - "$LOG_ARN" "$FMT" > "$PATCH" <<'PY'
import json, sys
print(json.dumps([
{"op":"replace","path":"/accessLogSettings/destinationArn","value":sys.argv[1]},
{"op":"replace","path":"/accessLogSettings/format","value":sys.argv[2]},
]))
PY
echo "--- patch payload ---"
cat "$PATCH"; echo
awslocal apigateway update-stage --rest-api-id "$API_ID" --stage-name prod \
--patch-operations "file://${PATCH}" 2>&1 | head -20
echo
echo "--- stage after update ---"
awslocal apigateway get-stage --rest-api-id "$API_ID" --stage-name prod \
--query 'accessLogSettings' --output json--- patch payload ---
[{"op": "replace", "path": "/accessLogSettings/destinationArn", "value": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api"}, {"op": "replace", "path": "/accessLogSettings/format", "value": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\",\"latency\":\"$context.responseLatency\"}"}]
{
"deploymentId": "awdhrafsxy",
"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\",\"latency\":\"$context.responseLatency\"}",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api"
},
"tracingEnabled": false,
"createdDate": "2026-04-29T18:28:45+00:00",
"lastUpdatedDate": "2026-04-29T18:31:15+00:00"
}
--- stage after update ---
{
"format": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\",\"latency\":\"$context.responseLatency\"}",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api"
}
[stdout]
--- patch payload ---
[{"op": "replace", "path": "/accessLogSettings/destinationArn", "value": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api"}, {"op": "replace", "path": "/accessLogSettings/format", "value": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\",\"latency\":\"$context.responseLatency\"}"}]
{
"deploymentId": "awdhrafsxy",
"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\",\"latency\":\"$context.responseLatency\"}",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api"
},
"tracingEnabled": false,
"createdDate": "2026-04-29T18:28:45+00:00",
"lastUpdatedDate": "2026-04-29T18:31:15+00:00"
}
--- stage after update ---
{
"format": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\",\"latency\":\"$context.responseLatency\"}",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api"
}/app/bootstrap.sh
# Deploy first (creates stage if absent)
awsl apigateway create-deployment --rest-api-id "$API_ID" --stage-name "$STAGE" >/dev/null
# Attach access log settings to the stage. Format = compact JSON of $context fields, no body.
ACCESS_FORMAT='{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","method":"$context.httpMethod","path":"$context.path","status":"$context.status","latency":"$context.responseLatency"}'
PATCH_FILE=$(mktemp)
python3 - "$APIGW_LOG_ARN" "$ACCESS_FORMAT" > "$PATCH_FILE" <<'PY'
import json, sys
arn, fmt = sys.argv[1], sys.argv[2]
print(json.dumps([
{"op": "replace", "path": "/accessLogSettings/destinationArn", "value": arn},
{"op": "replace", "path": "/accessLogSettings/format", "value": fmt},
]))
PY
awsl apigateway update-stage --rest-api-id "$API_ID" --stage-name "$STAGE" \
--patch-operations "file://${PATCH_FILE}" >/dev/null
# Redeploy after every wiring change
awsl apigateway create-deployment --rest-api-id "$API_ID" --stage-name "$STAGE" \
--description "post-wiring redeploy" >/dev/null# Deploy (creates stage if absent) , this is the post-wiring redeploy required by apigw v1.
awsl apigateway create-deployment --rest-api-id "$API_ID" --stage-name "$STAGE" \
--description "post-wiring redeploy" >/dev/null
# Attach access log settings to the stage AFTER deployment so the redeploy can't clobber them.
# Format = compact JSON of $context fields only; never the body (creds would leak).
ACCESS_FORMAT='{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","method":"$context.httpMethod","path":"$context.path","status":"$context.status","latency":"$context.responseLatency"}'
PATCH_FILE=$(mktemp)
python3 - "$APIGW_LOG_ARN" "$ACCESS_FORMAT" > "$PATCH_FILE" <<'PY'
import json, sys
arn, fmt = sys.argv[1], sys.argv[2]
print(json.dumps([
{"op": "replace", "path": "/accessLogSettings/destinationArn", "value": arn},
{"op": "replace", "path": "/accessLogSettings/format", "value": fmt},
]))
PY
awsl apigateway update-stage --rest-api-id "$API_ID" --stage-name "$STAGE" \
--patch-operations "file://${PATCH_FILE}" >/dev/nullRe-run bootstrap idempotently and verify access logs + round-trip
echo "--- re-running bootstrap end-to-end (idempotent) ---"
bash /app/bootstrap.sh 2>&1 | tail -20
echo
echo "--- final stage state ---"
API_ID=$(awslocal ssm get-parameter --name /harbor/saas/api-id --query 'Parameter.Value' --output text)
awslocal apigateway get-stage --rest-api-id "$API_ID" --stage-name prod \
--query '{deployment:deploymentId,access:accessLogSettings}' --output json
echo
echo "--- final round-trip ---"
curl -sS -o /tmp/t -w "HTTP %{http_code}\n" "http://localstack:4566/restapis/${API_ID}/prod/_user_request_/token?tenant=globex-eu"
python3 -c 'import json;d=json.load(open("/tmp/t"));print("tenant:",d["tenant"]);print("creds present:", all(k in d for k in ("AccessKeyId","SecretAccessKey","SessionToken")));print("expiration:",d["Expiration"])'--- re-running bootstrap end-to-end (idempotent) ---
=== lambda invoke permission (SourceArn pinned) ===
source arn: arn:aws:execute-api:us-east-1:000000000000:gnjfnp04kp/*/GET/token
=== apigw access logs ===
invoke base: http://localhost:4566/restapis/gnjfnp04kp/prod/_user_request_
=== ssm pointers ===
=== done ===
table: SaasOrders
table arn: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders
kms: alias/saas-orders-cmk (d093cad2-8b1f-43b4-aca0-d3e3ac195b31)
vendor role: arn:aws:iam::000000000000:role/TenantTokenVendorRole
data role: arn:aws:iam::000000000000:role/TenantDataRole
lambda: arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor
api id: gnjfnp04kp
stage: prod
invoke url: http://localhost:4566/restapis/gnjfnp04kp/prod/_user_request_
source arn pin: arn:aws:execute-api:us-east-1:000000000000:gnjfnp04kp/*/GET/token
--- final stage state ---
{
"deployment": "vz7jyufey7",
"access": {
"format": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\",\"latency\":\"$context.responseLatency\"}",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api"
}
}
--- final round-trip ---
HTTP 200
tenant: globex-eu
creds present: True
expiration: 2026-04-29T18:47:33.844129+00:00
[stdout]
--- re-running bootstrap end-to-end (idempotent) ---
=== lambda invoke permission (SourceArn pinned) ===
source arn: arn:aws:execute-api:us-east-1:000000000000:gnjfnp04kp/*/GET/token
=== apigw access logs ===
invoke base: http://localhost:4566/restapis/gnjfnp04kp/prod/_user_request_
=== ssm pointers ===
=== done ===
table: SaasOrders
table arn: arn:aws:dynamodb:us-east-1:000000000000:table/SaasOrders
kms: alias/saas-orders-cmk (d093cad2-8b1f-43b4-aca0-d3e3ac195b31)
vendor role: arn:aws:iam::000000000000:role/TenantTokenVendorRole
data role: arn:aws:iam::000000000000:role/TenantDataRole
lambda: arn:aws:lambda:us-east-1:000000000000:function:tenant-token-vendor
api id: gnjfnp04kp
stage: prod
invoke url: http://localhost:4566/restapis/gnjfnp04kp/prod/_user_request_
source arn pin: arn:aws:execute-api:us-east-1:000000000000:gnjfnp04kp/*/GET/token
--- final stage state ---
{
"deployment": "vz7jyufey7",
"access": {
"format": "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"method\":\"$context.httpMethod\",\"path\":\"$context.path\",\"status\":\"$context.status\",\"latency\":\"$context.responseLatency\"}",
"destinationArn": "arn:aws:logs:us-east-1:000000000000:log-group:/aws/apigateway/harbor-saas-api"
}
}
--- final round-trip ---
HTTP 200
tenant: globex-eu
creds present: True
expiration: 2026-04-29T18:47:33.844129+00:00No 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 120ms
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.1, pluggy-1.6.0 -- /root/.cache/uv/archive-v0/5z1-oTCgaby-_05REH-3J/bin/python
cachedir: .pytest_cache
rootdir: /tests
plugins: json-ctrf-0.3.5
collecting ... collected 35 items
../tests/test_outputs.py::test_01_table_exists_with_pk_and_sk PASSED [ 2%]
../tests/test_outputs.py::test_02_seed_items_two_per_tenant PASSED [ 5%]
../tests/test_outputs.py::test_03_lambda_exists_python311 PASSED [ 8%]
../tests/test_outputs.py::test_04_data_and_vendor_roles_exist PASSED [ 11%]
../tests/test_outputs.py::test_05_apigw_route_token_exists_and_ssm_pointers_resolve PASSED [ 14%]
../tests/test_outputs.py::test_06_data_role_trust_includes_sts_tagsession PASSED [ 17%]
../tests/test_outputs.py::test_07_data_role_trust_principal_is_vendor_role_arn PASSED [ 20%]
../tests/test_outputs.py::test_08_data_role_trust_request_tag_allowlist PASSED [ 22%]
../tests/test_outputs.py::test_09_data_role_inline_uses_principal_tag_substitution_literal PASSED [ 25%]
../tests/test_outputs.py::test_10_data_role_inline_uses_forallvalues_stringequals PASSED [ 28%]
../tests/test_outputs.py::test_11_data_role_inline_resource_is_table_arn_not_wildcard PASSED [ 31%]
../tests/test_outputs.py::test_12_data_role_inline_actions_scoped_no_scan_no_wildcard FAILED [ 34%]
../tests/test_outputs.py::test_13_lambda_source_uses_transitive_tag_keys_and_tags PASSED [ 37%]
../tests/test_outputs.py::test_14_lambda_source_validates_tenant_input_regex PASSED [ 40%]
../tests/test_outputs.py::test_15_lambda_source_duration_seconds_le_900 FAILED [ 42%]
../tests/test_outputs.py::test_16_vendor_role_can_only_assume_data_role PASSED [ 45%]
../tests/test_outputs.py::test_17_no_admin_managed_policies_attached PASSED [ 48%]
../tests/test_outputs.py::test_18_e2e_get_token_for_acme_returns_creds_and_query_works PASSED [ 51%]
../tests/test_outputs.py::test_19_evaluator_admits_acme_blocks_globex_with_session_tag PASSED [ 54%]
../tests/test_outputs.py::test_20_invalid_tenant_input_rejected PASSED [ 57%]
../tests/test_outputs.py::test_21_third_tenant_globex_eu_seeded_two_items PASSED [ 60%]
../tests/test_outputs.py::test_22_data_role_trust_action_set_has_no_extras PASSED [ 62%]
../tests/test_outputs.py::test_23_data_role_trust_requires_tenantid_tag_present PASSED [ 65%]
../tests/test_outputs.py::test_24_data_role_inline_uses_dynamodb_attributes_column_scope PASSED [ 68%]
../tests/test_outputs.py::test_25_data_role_inline_does_not_use_leadingkey_singular_typo PASSED [ 71%]
../tests/test_outputs.py::test_26_vendor_role_has_no_aws_managed_basic_execution PASSED [ 74%]
../tests/test_outputs.py::test_27_vendor_role_inline_no_wildcard_actions PASSED [ 77%]
../tests/test_outputs.py::test_28_lambda_runtime_python311_memory_le_256 PASSED [ 80%]
../tests/test_outputs.py::test_29_lambda_source_transitive_tag_keys_is_tenantid_only PASSED [ 82%]
../tests/test_outputs.py::test_30_apigw_access_log_group_exists PASSED [ 85%]
../tests/test_outputs.py::test_31_lambda_permission_sourcearn_pinned_to_api PASSED [ 88%]
../tests/test_outputs.py::test_32_ddb_table_uses_customer_managed_cmk_sse PASSED [ 91%]
../tests/test_outputs.py::test_33_lambda_handler_does_not_log_credentials PASSED [ 94%]
../tests/test_outputs.py::test_34_doc_evaluator_blocks_tenant_substitution_attacks PASSED [ 97%]
../tests/test_outputs.py::test_35_globex_eu_token_works_e2e PASSED [100%]
=================================== FAILURES ===================================
_________ test_12_data_role_inline_actions_scoped_no_scan_no_wildcard __________
def test_12_data_role_inline_actions_scoped_no_scan_no_wildcard():
"""Actions exclude Scan and wildcards."""
actions = set(_all_actions(_stmts(_inline_doc(ROLE_DATA))))
leaks = actions & FORBIDDEN_DDB_ACTIONS
assert not leaks, f"{ROLE_DATA} inline grants forbidden actions: {leaks}"
extras = {a for a in actions if a.startswith("dynamodb:")} - ALLOWED_DDB_ACTIONS
> assert not extras, f"{ROLE_DATA} inline grants extra ddb actions: {extras}"
E AssertionError: TenantDataRole inline grants extra ddb actions: {'dynamodb:DescribeTable'}
E assert not {'dynamodb:DescribeTable'}
/tests/test_outputs.py:347: AssertionError
________________ test_15_lambda_source_duration_seconds_le_900 _________________
def test_15_lambda_source_duration_seconds_le_900():
"""Duration is at most 900s."""
src = _lambda_source()
matches = re.findall(r"DurationSeconds\s*[=:]\s*(\d+)", src)
> assert matches, "lambda source does not set DurationSeconds"
E AssertionError: lambda source does not set DurationSeconds
E assert []
/tests/test_outputs.py:388: AssertionError
=============================== warnings summary ===============================
test_outputs.py: 60 warnings
/root/.cache/uv/archive-v0/5z1-oTCgaby-_05REH-3J/lib/python3.12/site-packages/botocore/auth.py:424: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
datetime_now = datetime.datetime.utcnow()
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED ../tests/test_outputs.py::test_01_table_exists_with_pk_and_sk
PASSED ../tests/test_outputs.py::test_02_seed_items_two_per_tenant
PASSED ../tests/test_outputs.py::test_03_lambda_exists_python311
PASSED ../tests/test_outputs.py::test_04_data_and_vendor_roles_exist
PASSED ../tests/test_outputs.py::test_05_apigw_route_token_exists_and_ssm_pointers_resolve
PASSED ../tests/test_outputs.py::test_06_data_role_trust_includes_sts_tagsession
PASSED ../tests/test_outputs.py::test_07_data_role_trust_principal_is_vendor_role_arn
PASSED ../tests/test_outputs.py::test_08_data_role_trust_request_tag_allowlist
PASSED ../tests/test_outputs.py::test_09_data_role_inline_uses_principal_tag_substitution_literal
PASSED ../tests/test_outputs.py::test_10_data_role_inline_uses_forallvalues_stringequals
PASSED ../tests/test_outputs.py::test_11_data_role_inline_resource_is_table_arn_not_wildcard
PASSED ../tests/test_outputs.py::test_13_lambda_source_uses_transitive_tag_keys_and_tags
PASSED ../tests/test_outputs.py::test_14_lambda_source_validates_tenant_input_regex
PASSED ../tests/test_outputs.py::test_16_vendor_role_can_only_assume_data_role
PASSED ../tests/test_outputs.py::test_17_no_admin_managed_policies_attached
PASSED ../tests/test_outputs.py::test_18_e2e_get_token_for_acme_returns_creds_and_query_works
PASSED ../tests/test_outputs.py::test_19_evaluator_admits_acme_blocks_globex_with_session_tag
PASSED ../tests/test_outputs.py::test_20_invalid_tenant_input_rejected
PASSED ../tests/test_outputs.py::test_21_third_tenant_globex_eu_seeded_two_items
PASSED ../tests/test_outputs.py::test_22_data_role_trust_action_set_has_no_extras
PASSED ../tests/test_outputs.py::test_23_data_role_trust_requires_tenantid_tag_present
PASSED ../tests/test_outputs.py::test_24_data_role_inline_uses_dynamodb_attributes_column_scope
PASSED ../tests/test_outputs.py::test_25_data_role_inline_does_not_use_leadingkey_singular_typo
PASSED ../tests/test_outputs.py::test_26_vendor_role_has_no_aws_managed_basic_execution
PASSED ../tests/test_outputs.py::test_27_vendor_role_inline_no_wildcard_actions
PASSED ../tests/test_outputs.py::test_28_lambda_runtime_python311_memory_le_256
PASSED ../tests/test_outputs.py::test_29_lambda_source_transitive_tag_keys_is_tenantid_only
PASSED ../tests/test_outputs.py::test_30_apigw_access_log_group_exists
PASSED ../tests/test_outputs.py::test_31_lambda_permission_sourcearn_pinned_to_api
PASSED ../tests/test_outputs.py::test_32_ddb_table_uses_customer_managed_cmk_sse
PASSED ../tests/test_outputs.py::test_33_lambda_handler_does_not_log_credentials
PASSED ../tests/test_outputs.py::test_34_doc_evaluator_blocks_tenant_substitution_attacks
PASSED ../tests/test_outputs.py::test_35_globex_eu_token_works_e2e
FAILED ../tests/test_outputs.py::test_12_data_role_inline_actions_scoped_no_scan_no_wildcard
FAILED ../tests/test_outputs.py::test_15_lambda_source_duration_seconds_le_900
================== 2 failed, 33 passed, 60 warnings in 7.09s ===================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_51e172b4db7447ed. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_51e172b4db7447ed · verifier authoritative; classifier explanatory.