Skip to content

Tenant API, Lambda authorizer and usage plans

Put a REST API in front of the tenant stack. Every request goes through a Lambda authorizer that turns the caller’s JWT into:

  1. tenant context (tenantId, userRole, tenantTier) passed to the backend,
  2. the API key of the tenant’s tier, so API Gateway applies that tier’s throttling and quota,
  3. temporary AWS credentials that can only touch this tenant’s data, used by services from step 05 on.

A small GET /whoami endpoint shows what the authorizer produced.

A GET /products request through API Gateway, the Lambda authorizer, STS and the product service

API Gateway caches the authorizer’s answer per token for 30 seconds, so the STS call isn’t made on every request. That’s why credentials are vended in the authorizer and not in each service.

AuthorizerAccessRole is a role that only the authorizer can assume. Each business service grants it read/write on its own table (step 05). When the authorizer assumes it, it passes an inline session policy:

{ "Effect": "Allow",
"Action": ["dynamodb:GetItem","dynamodb:PutItem","dynamodb:UpdateItem","dynamodb:DeleteItem","dynamodb:Query"],
"Resource": "arn:aws:dynamodb:<region>:<account>:table/*",
"Condition": { "ForAllValues:StringLike": { "dynamodb:LeadingKeys": ["tenant-a-*"] } } }

The temporary credentials get the intersection of the role’s permissions and this session policy. So even if a service has a bug and asks DynamoDB for tenant-b-3, AWS rejects the call. Scan is left out on purpose, because a scan would read every tenant’s items.

Only TenantAdmin and TenantUser get a policy. Any other role, such as SystemAdmin or a missing role, is denied, never given broader access (auth_manager.py).

Tier Stack Rate / burst (req/s) Quota / day
BASIC pooled 50 / 50 1,000
STANDARD pooled 75 / 100 3,000
PREMIUM pooled 100 / 200 5,000
PLATINUM silo 300 / 300 10,000

The API has apiKeySourceType: AUTHORIZER and apiKeyRequired: true. Clients never send an API key. The authorizer returns usageIdentifierKey and API Gateway counts the request against that key’s plan. A pooled stack only knows the Basic/Standard/Premium keys, so a Platinum user’s token is rejected there. That user belongs on their silo stack (step 09).

server/src/layers is deployed as one Lambda layer used by every function. Services just import logger, import utils and so on:

Module What it hides from services
logger.py Powertools logger; log_with_tenant_context adds tenant_id to every log line
metrics_manager.py CloudWatch EMF metrics with a tenant_id dimension
auth_manager.py Roles, who may manage users, the tenant-scoped IAM policy
utils.py Tiers, JSON responses with CORS, validate_shard_belongs_to_tenant
idp_object_factory.py + cognito/ Identity provider abstraction (only Cognito is implemented)

This is the reference solution’s “hide tenant details in a layer” idea: developers of the product service don’t write tenant plumbing.

Terminal window
cd server
python3 -m venv .venv && .venv/bin/pip install -r tests/requirements-test.txt
.venv/bin/python -m pytest tests -q # 26 passed

They cover JWT validation (wrong key, expired, wrong audience/issuer, access token), the authorizer (tier → key, rejected roles and tiers) and the isolation helpers.

Terminal window
./scripts/deploy.sh you@example.com

Output: serverless-saas-tenant-template-pooled.ApiGatewayUrl = https://<id>.execute-api.us-east-1.amazonaws.com/prod/

Terminal window
API=$(aws cloudformation describe-stacks --stack-name serverless-saas-tenant-template-pooled \
--query "Stacks[0].Outputs[?OutputKey=='ApiGatewayUrl'].OutputValue" --output text)
./scripts/dev-create-tenant-user.sh tenant-b PREMIUM TenantUser bob 'Passw0rd!Saas'
./scripts/dev-create-tenant-user.sh tenant-x PLATINUM TenantAdmin pat 'Passw0rd!Saas'
curl -s -H "Authorization: Bearer $(./scripts/tenant-token.sh alice 'Passw0rd!Saas')" ${API}whoami

Results from the test deployment:

Caller Response
no token / garbage token / access token 401 {"message":"Unauthorized"}
alice (tenant-a, BASIC, TenantAdmin) 200 {"apiKeyId":"6bo0lteh78","scopedCredentialsReceived":true,"tenantId":"tenant-a","tenantTier":"BASIC","userName":"alice","userRole":"TenantAdmin"}
bob (tenant-b, PREMIUM, TenantUser) 200 {"apiKeyId":"ct8pnfqj1l",...,"tenantId":"tenant-b","tenantTier":"PREMIUM",...}
pat (PLATINUM) on the pooled API 401, and the authorizer logs No tenant id or no API key for tier PLATINUM

apiKeyId matches the tier’s key id in SSM (apiKeyBasicTierKeyId=6bo0lteh78, apiKeyPremiumTierKeyId=ct8pnfqj1l).

Right after the first deploy, valid tokens may get 403 Forbidden for about a minute while the new usage plan ↔ API key links propagate. Just retry.

Tenant-aware logs in /aws/lambda/<WhoAmIFunction>:

{"level":"INFO","message":"whoami called","service":"WhoAmI","tenant_id":"tenant-b", ...}
{"_aws":{"CloudWatchMetrics":[{"Namespace":"ServerlessSaaS","Dimensions":[["tenant_id","service"]],"Metrics":[{"Name":"WhoAmICalled"}]}]},"tenant_id":"tenant-b","WhoAmICalled":[1.0]}

Step 05 adds the first real service, products, whose pooled DynamoDB table is protected by these scoped credentials.