Skip to content

Tenant isolation

Basic, Standard and Premium tenants share one product table and one order table. Isolation is enforced twice, so a bug in one layer doesn’t leak data.

Two layers of isolation: an ownership check in code, then IAM credentials that only allow the tenant's partition keys
Attribute Example
shardId (partition key) tenant-a-9, that is <tenantId>-<random 1..9>
productId or orderId (sort key) a UUID

Each tenant is spread over 9 partition keys so a busy tenant doesn’t create a hot partition. Listing a tenant’s items runs 9 Query calls in parallel. Scan is never used, because a scan would read every tenant’s items.

The API addresses an item by key = "<shardId>:<itemId>", for example tenant-a-9:e040650e-….

The key arrives in the URL, so a client could send another tenant’s key. Before touching DynamoDB, tenant_data.split_key checks that the shard belongs to the tenantId the authorizer asserted from the token, never anything from the request. A mismatch returns 403.

The authorizer assumes AuthorizerAccessRole and 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-*"] } } }

Temporary credentials get the intersection of the role’s permissions and this policy. The authorizer hands them to the service in requestContext.authorizer, and in the pooled stack the services use them, not their own Lambda role, for every DynamoDB call.

So even if the application check had a bug, DynamoDB itself refuses:

AccessDeniedException: ... assumed-role/...AuthorizerAccessRole.../tenant-tenant-a is not authorized to
perform: dynamodb:GetItem ... because no session policy allows the dynamodb:GetItem action

scripts/dev-isolation-check.sh gets a user’s real scoped credentials and calls DynamoDB with them, which proves this without going through the service code.

API Gateway caches the authorizer’s answer per token for 30 seconds. That’s why credentials are vended once in the authorizer and not by each service on each call.

Every pooled tenant’s users are in one Cognito user pool, and IAM can’t express “only users in your tenant’s group”. So user management rules are enforced in code, in the REST handler and again in the Cognito service class:

  • List with ListUsersInGroup(<tenantId>).
  • Before any single-user operation, check that AdminListGroupsForUser contains the caller’s tenant.
  • Only tenant roles can be assigned, even by a TenantAdmin.

A Platinum tenant’s stack has its own user pool, API and tables. The services use their own role (IS_POOLED_DEPLOY=false) because the table only ever holds one tenant. A token from one pool is rejected by the other stacks’ authorizers with 401, because the issuer and signature don’t match.

From the test deployment, with alice in tenant-a and bob in tenant-b:

Call Result
alice lists products only alice’s
alice GET, PUT or DELETE bob’s product key 403 each
alice’s scoped credentials GetItem on bob’s item AccessDeniedException
alice creates a user with "tenantId": "tenant-b" in the body the user is created in tenant-a
alice gives a user the SystemAdmin role 403