Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

## [v0.2.6] - 2026-06-20

Prepares the SDK for the production module transport. In production a module runs as a Lambda function invoked via the HTTP-shaped `LambdaRequest` envelope; this closes the one kernel gap on that receive path so a deployed module's internal/MCP auth works.

### Changed
- **`NewLambdaHandler` no longer strips the platform-auth secret headers.** The Lambda receive path strips spoofable `X-MS-*` identity *claims* (`X-MS-User-ID`, `X-MS-App-ID`, `X-MS-App-Role`) — trusted identity arrives via the typed `LambdaRequest` fields — but now **exempts the two platform-auth *secret* headers** (`X-MS-Internal-Secret`, `X-MS-Platform-Token`). Previously every `x-ms-*` header was dropped before the router ran, so a Lambda-invoked module's `InternalAuth` / `RequireProxy` middleware could never see the secret and rejected every internal/MCP call. The secrets are platform-injected credentials, not client-spoofable claims (the platform builds a fresh header set per invoke), so letting them through is safe and restores the documented `internalAuth` behavior on the Lambda path.

## [v0.2.5] - 2026-06-19

A module can now mark one of its own tables **read-only eligible** for a depending module — the producer half of the cross-module data contract. v0.2.0's design notes deferred this in favor of `pg_class` introspection; an explicit declaration is clearer (the producer's intent is in source, not inferred) and keeps the GRANT surface auditable, while preserving the same trust model: the producer marks a table readable, the **app owner** decides who reads it.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.5
0.2.6
33 changes: 31 additions & 2 deletions internal/runtime/lambda.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,29 @@ func jsonError(code int, msg string) LambdaResponse {
}
}

// msAuthSecretHeaders are the platform-injected auth SECRET headers (lower-cased
// for comparison). Unlike the spoofable identity-claim headers (x-ms-user-id,
// x-ms-app-id, x-ms-app-role), these are credentials the platform sets and the
// module's internalAuth / RequireProxy middleware must still validate — so they
// survive the x-ms-* strip on the Lambda path, exactly as the dev tunnel injects
// them. Kept in sync with auth.HeaderInternalSecret / auth.HeaderPlatformToken
// (asserted in lambda_test.go). See decisions/09 §4 (prod module transport).
var msAuthSecretHeaders = map[string]bool{
"x-ms-internal-secret": true,
"x-ms-platform-token": true,
}

// isStrippedIdentityHeader reports whether an inbound header must be dropped
// before the module router sees it: every x-ms-* header EXCEPT the platform-auth
// secrets in msAuthSecretHeaders. Trusted identity arrives via the typed
// LambdaRequest fields, so identity-claim headers are always stripped.
func isStrippedIdentityHeader(k string) bool {
if len(k) < 5 || !strings.EqualFold(k[:5], "x-ms-") {
return false
}
return !msAuthSecretHeaders[strings.ToLower(k)]
}

// NewLambdaHandler wraps an http.Handler into a function compatible with
// aws-lambda-go's lambda.Start().
func NewLambdaHandler(handler http.Handler) func(context.Context, json.RawMessage) (LambdaResponse, error) {
Expand All @@ -83,9 +106,15 @@ func NewLambdaHandler(handler http.Handler) func(context.Context, json.RawMessag
return jsonError(500, "failed to build request"), nil
}

// Copy headers but strip X-MS-* to prevent spoofing
// Copy headers, stripping spoofable X-MS-* identity CLAIMS (user/app/
// role) — trusted identity arrives via the typed LambdaRequest fields
// below, never from headers. The platform-auth SECRET headers are exempt
// (see msAuthSecretHeaders): they are credentials the module's
// internalAuth / RequireProxy middleware must still see. Safe because the
// platform builds a fresh header set per invoke — nothing client-supplied
// reaches here.
for k, v := range req.Headers {
if len(k) >= 5 && strings.EqualFold(k[:5], "x-ms-") {
if isStrippedIdentityHeader(k) {
continue
}
httpReq.Header.Set(k, v)
Expand Down
68 changes: 68 additions & 0 deletions internal/runtime/lambda_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"

"github.com/go-chi/chi/v5"
Expand Down Expand Up @@ -174,6 +175,73 @@ func TestNewLambdaHandler_StripXMSHeaders(t *testing.T) {
}
}

// TestNewLambdaHandler_AuthSecretHeadersSurvive pins the prod-transport
// contract (decisions/09 §4): the platform-auth SECRET headers must reach the
// module router so internalAuth / RequireProxy can validate them on the Lambda
// path, while identity-CLAIM headers stay stripped (identity rides typed fields).
func TestNewLambdaHandler_AuthSecretHeadersSurvive(t *testing.T) {
r := chi.NewRouter()
r.Get("/check", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{
"internalSecret": r.Header.Get(auth.HeaderInternalSecret),
"platformToken": r.Header.Get(auth.HeaderPlatformToken),
"userID": r.Header.Get(auth.HeaderUserID),
"appID": r.Header.Get(auth.HeaderAppID),
"appRole": r.Header.Get(auth.HeaderAppRole),
})
})

handler := NewLambdaHandler(r)
payload := mustMarshal(t, LambdaRequest{
Method: "GET",
Path: "/check",
Headers: map[string]string{
auth.HeaderInternalSecret: "platform-secret", // exempt — must survive
auth.HeaderPlatformToken: "platform-token", // exempt — must survive
auth.HeaderUserID: "spoofed-user", // claim — must be stripped
auth.HeaderAppID: "spoofed-app", // claim — must be stripped
auth.HeaderAppRole: "admin", // claim — must be stripped
},
})

resp, err := handler(context.Background(), payload)
requireNoErr(t, err)

var body map[string]string
if err := json.Unmarshal([]byte(resp.Body), &body); err != nil {
t.Fatalf("unmarshal handler response: %v", err)
}

if body["internalSecret"] != "platform-secret" {
t.Errorf("%s must survive the strip, got %q", auth.HeaderInternalSecret, body["internalSecret"])
}
if body["platformToken"] != "platform-token" {
t.Errorf("%s must survive the strip, got %q", auth.HeaderPlatformToken, body["platformToken"])
}
for _, claim := range []string{"userID", "appID", "appRole"} {
if body[claim] != "" {
t.Errorf("identity claim %q must be stripped, got %q", claim, body[claim])
}
}
}

// TestMsAuthSecretHeadersMatchConstants structurally pins the exempt-header map
// to the auth package constants. The behavioral test above sends whatever the
// constants say, so a rename of a constant WITHOUT updating the map would slip
// past it; this test fails closed on that drift.
func TestMsAuthSecretHeadersMatchConstants(t *testing.T) {
for _, h := range []string{auth.HeaderInternalSecret, auth.HeaderPlatformToken} {
if !msAuthSecretHeaders[strings.ToLower(h)] {
t.Errorf("auth secret header %q is missing from the msAuthSecretHeaders exempt set", h)
}
}
for _, h := range []string{auth.HeaderUserID, auth.HeaderAppID, auth.HeaderAppRole} {
if msAuthSecretHeaders[strings.ToLower(h)] {
t.Errorf("identity claim %q must NOT be exempt from the strip", h)
}
}
}

func TestNewLambdaHandler_InvalidSchema(t *testing.T) {
handler := NewLambdaHandler(chi.NewRouter())
payload := mustMarshal(t, LambdaRequest{
Expand Down
Loading