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
11 changes: 11 additions & 0 deletions auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
27 changes: 27 additions & 0 deletions auth/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 68 additions & 25 deletions system/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}
Expand All @@ -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},
Expand All @@ -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
}
Expand All @@ -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},
Expand All @@ -224,25 +271,21 @@ 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.
//
// The numeric check here duplicates the parse inside migration.Slice, which
// 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 == "" {
Expand Down
156 changes: 156 additions & 0 deletions system/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading