diff --git a/auth/middleware.go b/auth/middleware.go index 8f866a2..eab8e9b 100644 --- a/auth/middleware.go +++ b/auth/middleware.go @@ -183,6 +183,17 @@ func internalAuth(inLambda bool) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Payload-trusted: the request entered through runtime.NewLambdaHandler + // (real Lambda behind IAM, or the dev lambda-invoke shim behind its + // envelope-secret gate) — the envelope IS the platform authentication, + // and the deployed path has no per-session tunnel token to present. + // Mirrors RequireProxy: the mark is set only by the lambda entry, + // never from inbound request data, so a direct caller cannot forge it. + if PayloadTrusted(r.Context()) { + next.ServeHTTP(w, r) + return + } + expected, header, configured := readSecret() if expected == "" { diff --git a/auth/middleware_test.go b/auth/middleware_test.go index 044713c..195fc05 100644 --- a/auth/middleware_test.go +++ b/auth/middleware_test.go @@ -207,6 +207,33 @@ func TestInternalAuth_ValidSecret(t *testing.T) { } } +// A payload-trusted request (entered via runtime.NewLambdaHandler — real +// Lambda or the dev shim behind its envelope-secret gate) passes internal +// auth with NO header, even when a secret is configured: the deployed path +// has no per-session tunnel token to present. Mirrors RequireProxy. +func TestInternalAuth_PayloadTrusted_NoHeader_Passes(t *testing.T) { + t.Setenv("MS_PLATFORM_TOKEN", "tunnel-session-token") + handler := InternalAuth()(http.HandlerFunc(okHandler)) + + req := httptest.NewRequest("POST", "/__mirrorstack/platform/lifecycle/app/install", nil) + req = req.WithContext(WithPayloadTrust(req.Context())) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200 for payload-trusted request, got %d", rec.Code) + } + + // The same request WITHOUT the trust mark must still be rejected — + // trust never comes from inbound request data. + untrusted := httptest.NewRequest("POST", "/__mirrorstack/platform/lifecycle/app/install", nil) + rec2 := httptest.NewRecorder() + handler.ServeHTTP(rec2, untrusted) + if rec2.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for untrusted request, got %d", rec2.Code) + } +} + func TestInternalAuth_NoSecret_NotLambda_Bypasses(t *testing.T) { // Local dev with no secret configured: bypass auth so `mirrorstack dev` // can serve /__mirrorstack/* directly without the developer exporting a diff --git a/system/lifecycle.go b/system/lifecycle.go index 1646745..fc06a3c 100644 --- a/system/lifecycle.go +++ b/system/lifecycle.go @@ -30,15 +30,31 @@ type LifecycleError struct { Error string `json:"error"` } -// VersionRequest is the body shape for upgrade and downgrade. Both fields -// MUST be numeric migration numbers ("0008"), not semver strings ("v0.1.0"). -// The platform does semver→migration translation before calling the SDK, using -// the Versions map exposed via the manifest endpoint. +// VersionRequest is the migration window for upgrade and downgrade. Both +// fields MUST be numeric migration numbers ("0008"), not semver strings +// ("v0.1.0"). The platform does semver→migration translation before calling +// the SDK, using the Versions map exposed via the manifest endpoint. type VersionRequest struct { From string `json:"from"` To string `json:"to"` } +// UpgradeRequest is the full body shape for upgrade and downgrade: the +// migration window plus the same optional install context the platform sends +// on install (appId, schema, credential). It mirrors api-platform's +// upgradeRequest verbatim — the wire shape is the contract. +// +// A bare {from, to} body (the dev-tunnel path, where the module runs against +// its own env DB) keeps the pre-existing behavior: no schema or credential is +// injected and migrations run on the SDK's DATABASE_URL pool. When the +// platform drives a DEPLOYED (non-tunnel) upgrade it populates Schema + +// Credential so the (from, to] migrations run under the per-(app, module) +// r_* role against the app schema — exactly like install. +type UpgradeRequest struct { + VersionRequest + InstallRequest +} + // InstallRequest is the optional body for the install endpoint. The // platform populates Credential + Schema so the SDK runs migrations // under the per-(app, module) role provisioned at install time; AppID @@ -119,14 +135,20 @@ func injectInstallContext(w http.ResponseWriter, r *http.Request) (context.Conte if errors.Is(err, io.EOF) { return ctx, true } - var mbe *http.MaxBytesError - if errors.As(err, &mbe) { - httputil.JSON(w, http.StatusRequestEntityTooLarge, httputil.ErrorResponse{Error: "request body too large"}) - } else { - httputil.JSON(w, http.StatusBadRequest, httputil.ErrorResponse{Error: "invalid request body: " + err.Error()}) - } + writeBodyDecodeError(w, err) return ctx, false } + return injectLifecycleContext(w, ctx, req) +} + +// injectLifecycleContext folds an already-decoded install context (Schema + +// Credential) into ctx. It is the single implementation shared by install +// (whole body) and upgrade/downgrade (embedded in UpgradeRequest) so the +// credential semantics cannot drift between the lifecycle verbs. +// +// Returns ok=false after writing a 500 when the env DB base cannot be +// resolved; callers must return without touching w again. +func injectLifecycleContext(w http.ResponseWriter, ctx context.Context, req InstallRequest) (context.Context, bool) { if req.Schema != "" { ctx = db.WithSchema(ctx, req.Schema) } @@ -148,13 +170,33 @@ func injectInstallContext(w http.ResponseWriter, r *http.Request) (context.Conte return ctx, true } +// writeBodyDecodeError maps a JSON body decode failure onto the lifecycle +// error contract: 413 when the MaxBytes cap tripped, 400 otherwise. +func writeBodyDecodeError(w http.ResponseWriter, err error) { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + httputil.JSON(w, http.StatusRequestEntityTooLarge, httputil.ErrorResponse{Error: "request body too large"}) + return + } + httputil.JSON(w, http.StatusBadRequest, httputil.ErrorResponse{Error: "invalid request body: " + err.Error()}) +} + // UpgradeHandler applies the migrations strictly between (req.From, req.To]. // Both fields must be migration numbers; semver must be translated by the // platform before calling this endpoint (the platform reads the Versions map // from the manifest and uses the scope-matching field of MigrationVersions). +// +// When the body also carries Schema + Credential (the platform's non-tunnel +// path), the migrations run under that per-(app, module) credential with +// search_path = schema — identical to InstallHandler. A bare {from, to} +// body keeps the dev-tunnel env-pool behavior. func UpgradeHandler(sqlFS fs.FS, scope migration.Scope, runTx migration.TxRunner) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - req, ok := decodeVersionRequest(w, r) + req, ok := decodeUpgradeRequest(w, r) + if !ok { + return + } + ctx, ok := injectLifecycleContext(w, r.Context(), req.InstallRequest) if !ok { return } @@ -170,7 +212,7 @@ func UpgradeHandler(sqlFS fs.FS, scope migration.Scope, runTx migration.TxRunner return } - applied, err := migration.Apply(r.Context(), runTx, sqlFS, slice) + applied, err := migration.Apply(ctx, runTx, sqlFS, slice) if err != nil { httputil.JSON(w, http.StatusInternalServerError, LifecycleError{ LifecycleResult: LifecycleResult{Applied: applied}, @@ -185,9 +227,14 @@ func UpgradeHandler(sqlFS fs.FS, scope migration.Scope, runTx migration.TxRunner // DowngradeHandler reverts migrations between (req.To, req.From] in newest-first // order. Each migration must have a .down.sql or the request fails before any // SQL runs. Both fields must be migration numbers (see UpgradeHandler). +// Schema + Credential in the body behave exactly as in UpgradeHandler. func DowngradeHandler(sqlFS fs.FS, scope migration.Scope, runTx migration.TxRunner) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - req, ok := decodeVersionRequest(w, r) + req, ok := decodeUpgradeRequest(w, r) + if !ok { + return + } + ctx, ok := injectLifecycleContext(w, r.Context(), req.InstallRequest) if !ok { return } @@ -203,7 +250,7 @@ func DowngradeHandler(sqlFS fs.FS, scope migration.Scope, runTx migration.TxRunn return } - reverted, err := migration.ApplyDown(r.Context(), runTx, sqlFS, slice) + reverted, err := migration.ApplyDown(ctx, runTx, sqlFS, slice) if err != nil { httputil.JSON(w, http.StatusInternalServerError, LifecycleError{ LifecycleResult: LifecycleResult{Reverted: reverted}, @@ -224,9 +271,10 @@ func UninstallHandler() http.HandlerFunc { } } -// decodeVersionRequest reads and validates the {from, to} body. Both fields -// are required and must be numeric migration numbers (not semver) — the -// platform does semver resolution before calling. Returns ok=false after +// decodeUpgradeRequest reads and validates the upgrade/downgrade body. From +// and To are required and must be numeric migration numbers (not semver) — +// the platform does semver resolution before calling. AppID, Schema and +// Credential are optional (see UpgradeRequest). Returns ok=false after // writing a 400 response if the body is missing, malformed, or contains // non-numeric version values. // @@ -234,15 +282,10 @@ func UninstallHandler() http.HandlerFunc { // is intentional: rejecting semver at the boundary gives us a tailored error // message pointing at the platform-side translation step, and fast-fails // before we bother reading the sql/ directory. -func decodeVersionRequest(w http.ResponseWriter, r *http.Request) (VersionRequest, bool) { - var req VersionRequest +func decodeUpgradeRequest(w http.ResponseWriter, r *http.Request) (UpgradeRequest, bool) { + var req UpgradeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - var mbe *http.MaxBytesError - if errors.As(err, &mbe) { - httputil.JSON(w, http.StatusRequestEntityTooLarge, httputil.ErrorResponse{Error: "request body too large"}) - } else { - httputil.JSON(w, http.StatusBadRequest, httputil.ErrorResponse{Error: "invalid request body: " + err.Error()}) - } + writeBodyDecodeError(w, err) return req, false } if req.From == "" || req.To == "" { diff --git a/system/lifecycle_test.go b/system/lifecycle_test.go index c912a49..730810e 100644 --- a/system/lifecycle_test.go +++ b/system/lifecycle_test.go @@ -337,6 +337,162 @@ func TestInstallHandler_MalformedBody_400(t *testing.T) { } } +func TestUpgradeHandler_BodyInjectsCredentialAndSchema(t *testing.T) { + // Not t.Parallel because t.Setenv on MS_LOCAL_DB_URL would race with + // concurrent tests reading the env base. + + // Point the env base at a known URL so we can assert host/port/database + // come from the environment, not the body — the exact contract install + // already pins (see TestInstallHandler_BodyInjectsCredentialAndSchema). + t.Setenv("MS_LOCAL_DB_URL", "postgres://envuser:envpw@db.platform.local:6543/platform_apps?sslmode=disable") + + var captured context.Context + h := UpgradeHandler(oneMigrationFS(), migration.ScopeApp, capturingTxRunner(t, &captured)) + + body := strings.NewReader(`{ + "from": "0000", + "to": "0001", + "appId": "6c8d1234-abcd-ef01-2345-6789abcdef00", + "schema": "app_6c8d1234_abcd_ef01_2345_6789abcdef00", + "credential": { + "username": "r_6c8d1234_oauth-core", + "token": "secret-token" + } + }`) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/upgrade", body)) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if captured == nil { + t.Fatal("runner never invoked — Apply short-circuited unexpectedly") + } + if got := db.SchemaFrom(captured); got != "app_6c8d1234_abcd_ef01_2345_6789abcdef00" { + t.Errorf("SchemaFrom = %q, want app_6c8d1234_...", got) + } + cred := db.CredentialFrom(captured) + if cred == nil { + t.Fatal("CredentialFrom returned nil; expected per-(app, module) credential") + } + if cred.Username != "r_6c8d1234_oauth-core" || cred.Token != "secret-token" { + t.Errorf("credential per-install = username=%q token=%q, want r_6c8d1234_oauth-core / secret-token", cred.Username, cred.Token) + } + if cred.Host != "db.platform.local" || cred.Port != 6543 || cred.Database != "platform_apps" { + t.Errorf("credential env-base = host=%q port=%d db=%q, want db.platform.local/6543/platform_apps", cred.Host, cred.Port, cred.Database) + } +} + +func TestUpgradeHandler_FromToOnlyKeepsDevPath(t *testing.T) { + t.Parallel() + + // The dev-tunnel path posts only {from, to}. The handler must succeed + // and leave ctx without schema or credential so resolvePoolFor falls + // back to the dev pool — pre-existing behavior, unchanged. + var captured context.Context + h := UpgradeHandler(oneMigrationFS(), migration.ScopeApp, capturingTxRunner(t, &captured)) + body := strings.NewReader(`{"from": "0000", "to": "0001"}`) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/upgrade", body)) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if captured == nil { + t.Fatal("runner never invoked — Apply short-circuited unexpectedly") + } + if got := db.SchemaFrom(captured); got != "" { + t.Errorf("SchemaFrom = %q, want empty for from/to-only body", got) + } + if db.CredentialFrom(captured) != nil { + t.Errorf("CredentialFrom = %+v, want nil for from/to-only body", db.CredentialFrom(captured)) + } +} + +func TestUpgradeHandler_MalformedCredential_400(t *testing.T) { + t.Parallel() + + // A credential of the wrong JSON type must be a clean 400 from the body + // decoder — never a panic, never a silent env-pool fallback. + cases := []struct { + name string + body string + }{ + {"credential wrong type", `{"from": "0000", "to": "0001", "credential": "nope"}`}, + {"credential array", `{"from": "0000", "to": "0001", "credential": ["u", "t"]}`}, + {"schema wrong type", `{"from": "0000", "to": "0001", "schema": 42}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + h := UpgradeHandler(oneMigrationFS(), migration.ScopeApp, noopTxRunner(t)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/upgrade", strings.NewReader(tc.body))) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 for body %q", rec.Code, tc.body) + } + }) + } +} + +func TestUpgradeHandler_EmptyCredentialObjectFallsThrough(t *testing.T) { + t.Parallel() + + // `"credential": {}` (shape compat) must behave like no credential at + // all — same rule injectInstallContext applies on install. + var captured context.Context + h := UpgradeHandler(oneMigrationFS(), migration.ScopeApp, capturingTxRunner(t, &captured)) + body := strings.NewReader(`{"from": "0000", "to": "0001", "credential": {}}`) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/upgrade", body)) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if db.CredentialFrom(captured) != nil { + t.Error("CredentialFrom should be nil for an empty credential object") + } +} + +func TestDowngradeHandler_BodyInjectsCredentialAndSchema(t *testing.T) { + // Not t.Parallel — t.Setenv (see the upgrade variant). + + // Downgrade shares decodeUpgradeRequest + injectLifecycleContext with + // upgrade; pin that the credential context flows into ApplyDown too. + t.Setenv("MS_LOCAL_DB_URL", "postgres://envuser:envpw@db.platform.local:6543/platform_apps?sslmode=disable") + + fsys := fstest.MapFS{ + "sql/app/0001_init.up.sql": &fstest.MapFile{Data: []byte("")}, + "sql/app/0001_init.down.sql": &fstest.MapFile{Data: []byte("")}, + } + var captured context.Context + h := DowngradeHandler(fsys, migration.ScopeApp, capturingTxRunner(t, &captured)) + body := strings.NewReader(`{ + "from": "0001", + "to": "0000", + "schema": "app_6c8d1234_abcd_ef01_2345_6789abcdef00", + "credential": {"username": "r_6c8d1234_oauth-core", "token": "secret-token"} + }`) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/downgrade", body)) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if captured == nil { + t.Fatal("runner never invoked — ApplyDown short-circuited unexpectedly") + } + if got := db.SchemaFrom(captured); got != "app_6c8d1234_abcd_ef01_2345_6789abcdef00" { + t.Errorf("SchemaFrom = %q, want app_6c8d1234_...", got) + } + cred := db.CredentialFrom(captured) + if cred == nil || cred.Username != "r_6c8d1234_oauth-core" || cred.Token != "secret-token" { + t.Errorf("CredentialFrom = %+v, want r_6c8d1234_oauth-core / secret-token", cred) + } +} + func TestInstallHandler_PartialBodyOmitsMissingFields(t *testing.T) { t.Parallel()