diff --git a/docs/guide/src/features/multi-tenancy.md b/docs/guide/src/features/multi-tenancy.md index a763cd5a..581cdda4 100644 --- a/docs/guide/src/features/multi-tenancy.md +++ b/docs/guide/src/features/multi-tenancy.md @@ -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. + +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 @@ -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. diff --git a/src/lib.rs b/src/lib.rs index fb5cbbce..1fa91b18 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1600,7 +1600,10 @@ impl Adapter { 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); @@ -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); @@ -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| { + // 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"); + }); + + 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