Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions docs/guide/src/features/multi-tenancy.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,37 @@
# Multi-Tenancy

Lambda Web Adapter supports multi-tenancy by automatically propagating the tenant ID from the Lambda runtime context to your web application.
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.

## How It Works

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.
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.

The adapter reads the tenant ID only from the invocation context, never from the forwarded request. It removes any `X-Amz-Tenant-Id` header the caller sent before setting its own, so your application never reads a caller-supplied value from this header.
Comment thread
bnusunny marked this conversation as resolved.

That makes the header exactly as trustworthy as whatever puts the tenant ID into the invocation context. Getting that part right is the subject of the prerequisites below.

> **Warning:** the stripping exists only while the adapter is in the request path — that is, when your application runs on Lambda behind the adapter. If you run the same image or application **without** the adapter (Amazon ECS, Amazon EKS, a local Docker host), nothing removes a caller-supplied `X-Amz-Tenant-Id`, and an application that treats it as an asserted identity is reading raw caller input. Establish the tenant another way in those deployments, or reject the header at your edge.

## Prerequisites

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:

- **Tenant isolation must be enabled when the function is created.** It is an immutable function property and cannot be added to an existing function.
- **Function URLs are not supported**, and neither are provisioned concurrency or SnapStart.
- **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.
- **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.

With API Gateway REST you map a request property to `integration.request.header.X-Amz-Tenant-Id`, which is the header Lambda's `Invoke` API reads. **Map it from something the caller cannot choose** — an authorizer context value or a verified token claim:

```text
integration.request.header.X-Amz-Tenant-Id = context.authorizer.tenantId
```

A raw client header is not such a source. Mapping `method.request.header.x-tenant-id` straight through lets any caller name their own tenant: API Gateway forwards the value it was given, Lambda puts it in the invocation context, and the adapter then asserts it as `X-Amz-Tenant-Id` — so the application receives a caller-chosen tenant that looks like a platform-asserted one. Have your authorizer establish the tenant from the caller's credentials and map that.

See [Invoking Lambda functions with tenant isolation](https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation-invoke.html) for the full setup.

If the function does not use tenant isolation, no request carries a tenant ID and the adapter sets no `X-Amz-Tenant-Id` header.

## Reading the Tenant ID

Expand All @@ -22,4 +49,15 @@ app.get('/', (req, res) => {
});
```

No additional configuration is required.
The adapter itself needs no configuration; the prerequisites above are function and API Gateway settings.

## Do Not Fall Back to a Client-Supplied Tenant

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:

```python
# Don't do this: the caller chooses the tenant.
tenant_id = request.headers.get("x-amz-tenant-id") or request.headers.get("x-tenant-id")
```

A missing header on a tenant-isolated function means the deployment is wrong, not that the request belongs to a default tenant.
56 changes: 55 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1600,7 +1600,10 @@ impl Adapter<HttpConnector, Body> {
HeaderValue::from_bytes(&strip_forbidden_header_bytes(&serde_json::to_string(&lambda_context)?))?,
);

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

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

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

#[tokio::test]
async fn test_context_tenant_id_wins_over_client_supplied_header() {
let app_server = MockServer::start();
app_server.mock(|when, then| {
Comment thread
bnusunny marked this conversation as resolved.
// This does NOT guard the `remove` call above: on the `Some` path `insert`
// already drops every prior value, so the app sees only the context tenant
// either way. `test_tenant_id_header_absent_when_no_tenant` is the test that
// fails without it. What this pins is precedence -- the context value is the
// ONLY value forwarded -- so asserting the complete value set keeps it honest
// if `insert` ever becomes `append`.
when.method(GET).path("/hello").is_true(|req| {
req.headers()
.iter()
.filter(|(k, _)| k.as_str() == "x-amz-tenant-id")
.map(|(_, v)| v.to_str().unwrap_or_default())
.eq(["tenant-from-context"])
});
then.status(200).body("OK");
});
Comment thread
bnusunny marked this conversation as resolved.

let options = AdapterOptions {
host: app_server.host(),
port: app_server.port().to_string(),
readiness_check_port: app_server.port().to_string(),
readiness_check_path: "/".to_string(),
..Default::default()
};

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

let alb_req = lambda_http::request::LambdaRequest::Alb({
let mut req = lambda_http::aws_lambda_events::alb::AlbTargetGroupRequest::default();
req.http_method = Method::GET;
req.path = Some("/hello".into());
req.headers
.insert("x-amz-tenant-id", "client-supplied".parse().unwrap());
req
});
let mut request = Request::from(alb_req);
request
.extensions_mut()
.insert(make_lambda_context(Some("tenant-from-context")));

let response = adapter.fetch_response(request).await.expect("Request failed");
assert_eq!(200, response.status().as_u16());
}

#[test]
fn test_strip_forbidden_header_bytes() {
// Tab (0x09) and printable ASCII are preserved; CR/LF, NUL, DEL, and other
Expand Down
Loading