Skip to content

Commit 0c74608

Browse files
committed
fix: drop a client-supplied x-amz-tenant-id before setting the context value
The adapter asserts `x-amz-tenant-id` from the Lambda invocation context, but it only ever set the header, never cleared one the caller had sent. When the context carried no tenant ID the block was skipped entirely, so a caller's own `x-amz-tenant-id` was forwarded to the application untouched — the opposite of what the multi-tenancy guide promised. Remove the header before the conditional insert, the same way the sibling `x-amzn-request-context` and `x-amzn-lambda-context` headers are set unconditionally. The adapter now either sets this header itself or leaves the application with none. Also rewrite the multi-tenancy guide. It claimed no additional configuration was required, which is wrong in a way that matters: the tenant ID only arrives when the function uses Lambda tenant isolation, which is immutable at creation time, excludes function URLs, and among HTTP triggers works only with API Gateway REST. A reader who followed the old guide on a function URL would see an empty tenant on every request, and the tempting workaround is to start trusting a caller-supplied header.
1 parent 64a89c2 commit 0c74608

2 files changed

Lines changed: 74 additions & 4 deletions

File tree

‎docs/guide/src/features/multi-tenancy.md‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
# Multi-Tenancy
22

3-
Lambda Web Adapter supports multi-tenancy by automatically propagating the tenant ID from the Lambda runtime context to your web application.
3+
Lambda Web Adapter supports multi-tenancy by propagating the tenant ID from the Lambda invocation context to your web application as an `X-Amz-Tenant-Id` HTTP header.
44

55
## How It Works
66

7-
When the Lambda runtime includes a `tenant_id` in the invocation context, the adapter forwards it as an `X-Amz-Tenant-Id` HTTP header. If no tenant ID is present, the header is omitted.
7+
When the Lambda invocation context carries a tenant ID, the adapter forwards it as an `X-Amz-Tenant-Id` HTTP header. When it does not, the adapter sets no such header.
8+
9+
The tenant ID comes from the Lambda platform, never from the request. The adapter removes any `X-Amz-Tenant-Id` header the caller sent before setting its own, so your application never reads a client-supplied value from this header.
10+
11+
## Prerequisites
12+
13+
The tenant ID only reaches your application if the function uses [Lambda tenant isolation](https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation.html). That carries constraints worth knowing before you build on it:
14+
15+
- **Tenant isolation must be enabled when the function is created.** It is an immutable function property and cannot be added to an existing function.
16+
- **Function URLs are not supported**, and neither are provisioned concurrency or SnapStart.
17+
- **API Gateway REST APIs are the only supported HTTP trigger.** HTTP APIs cannot be used, because they cannot override the `X-Amz-Tenant-Id` header that Lambda's `Invoke` API requires.
18+
- **Every invocation must carry a tenant ID.** Lambda rejects an invocation of a tenant-isolated function that has none, so such a request fails before your application runs.
19+
20+
With API Gateway REST, map a client request property to the header Lambda expects — for example a client `x-tenant-id` header to `integration.request.header.X-Amz-Tenant-Id`. See [Invoking Lambda functions with tenant isolation](https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation-invoke.html) for the full setup.
21+
22+
If the function does not use tenant isolation, no request carries a tenant ID and the adapter sets no `X-Amz-Tenant-Id` header.
823

924
## Reading the Tenant ID
1025

@@ -22,4 +37,15 @@ app.get('/', (req, res) => {
2237
});
2338
```
2439

25-
No additional configuration is required.
40+
The adapter itself needs no configuration; the prerequisites above are function and API Gateway settings.
41+
42+
## Do Not Fall Back to a Client-Supplied Tenant
43+
44+
If your application scopes data by tenant, treat a missing `X-Amz-Tenant-Id` as an error rather than falling back to a default tenant or to another header the caller controls. A fallback like this turns the tenant identity into caller input:
45+
46+
```python
47+
# Don't do this: the caller chooses the tenant.
48+
tenant_id = request.headers.get("x-amz-tenant-id") or request.headers.get("x-tenant-id")
49+
```
50+
51+
A missing header on a tenant-isolated function means the deployment is wrong, not that the request belongs to a default tenant.

‎src/lib.rs‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1600,7 +1600,10 @@ impl Adapter<HttpConnector, Body> {
16001600
HeaderValue::from_bytes(&strip_forbidden_header_bytes(&serde_json::to_string(&lambda_context)?))?,
16011601
);
16021602

1603-
// Multi-tenancy support: propagate tenant_id from Lambda context
1603+
// Multi-tenancy support: propagate tenant_id from Lambda context.
1604+
// The adapter asserts this header, so a client-supplied copy is always dropped
1605+
// first -- the same way the two context headers above are unconditionally set.
1606+
req_headers.remove(HeaderName::from_static("x-amz-tenant-id"));
16041607
if let Some(ref tenant_id) = lambda_context.tenant_id {
16051608
if let Ok(value) = HeaderValue::from_str(tenant_id) {
16061609
req_headers.insert(HeaderName::from_static("x-amz-tenant-id"), value);
@@ -2506,10 +2509,14 @@ mod tests {
25062509

25072510
let adapter = Adapter::new(&options).expect("Failed to create adapter");
25082511

2512+
// The caller sets the header too. The app must still not see one: the adapter
2513+
// asserts this header, so a client-supplied value is never passed through.
25092514
let alb_req = lambda_http::request::LambdaRequest::Alb({
25102515
let mut req = lambda_http::aws_lambda_events::alb::AlbTargetGroupRequest::default();
25112516
req.http_method = Method::GET;
25122517
req.path = Some("/hello".into());
2518+
req.headers
2519+
.insert("x-amz-tenant-id", "client-supplied".parse().unwrap());
25132520
req
25142521
});
25152522
let mut request = Request::from(alb_req);
@@ -2519,6 +2526,43 @@ mod tests {
25192526
assert_eq!(200, response.status().as_u16());
25202527
}
25212528

2529+
#[tokio::test]
2530+
async fn test_context_tenant_id_wins_over_client_supplied_header() {
2531+
let app_server = MockServer::start();
2532+
app_server.mock(|when, then| {
2533+
when.method(GET)
2534+
.path("/hello")
2535+
.header("x-amz-tenant-id", "tenant-from-context");
2536+
then.status(200).body("OK");
2537+
});
2538+
2539+
let options = AdapterOptions {
2540+
host: app_server.host(),
2541+
port: app_server.port().to_string(),
2542+
readiness_check_port: app_server.port().to_string(),
2543+
readiness_check_path: "/".to_string(),
2544+
..Default::default()
2545+
};
2546+
2547+
let adapter = Adapter::new(&options).expect("Failed to create adapter");
2548+
2549+
let alb_req = lambda_http::request::LambdaRequest::Alb({
2550+
let mut req = lambda_http::aws_lambda_events::alb::AlbTargetGroupRequest::default();
2551+
req.http_method = Method::GET;
2552+
req.path = Some("/hello".into());
2553+
req.headers
2554+
.insert("x-amz-tenant-id", "client-supplied".parse().unwrap());
2555+
req
2556+
});
2557+
let mut request = Request::from(alb_req);
2558+
request
2559+
.extensions_mut()
2560+
.insert(make_lambda_context(Some("tenant-from-context")));
2561+
2562+
let response = adapter.fetch_response(request).await.expect("Request failed");
2563+
assert_eq!(200, response.status().as_u16());
2564+
}
2565+
25222566
#[test]
25232567
fn test_strip_forbidden_header_bytes() {
25242568
// Tab (0x09) and printable ASCII are preserved; CR/LF, NUL, DEL, and other

0 commit comments

Comments
 (0)