diff --git a/auth/middleware.go b/auth/middleware.go index 9270cfc..b9f3322 100644 --- a/auth/middleware.go +++ b/auth/middleware.go @@ -10,17 +10,111 @@ import ( "github.com/mirrorstack-ai/app-module-sdk/internal/lambdaenv" ) +// Trusted-forwarder identity-injection headers. A caller proves it's a +// trusted forwarder (the platform or its dispatch) by sending a valid +// X-MS-Internal-Secret; if proven, PlatformAuth trusts the user identity +// those callers assert via these headers. +const ( + HeaderInternalSecret = "X-MS-Internal-Secret" + HeaderUserID = "X-MS-User-ID" + HeaderAppID = "X-MS-App-ID" + HeaderAppRole = "X-MS-App-Role" +) + // PlatformAuth returns middleware that requires an authenticated user. // Use RequirePermission per-route for authorization (which roles can access). +// +// Identity sources (first match wins): +// +// 1. An Identity already set in ctx (the Lambda runtime's authorizer +// path puts one here before this middleware runs). +// 2. Trusted-forwarder injection: a request that presents a valid +// X-MS-Internal-Secret may also assert user identity via +// X-MS-User-ID / X-MS-App-ID / X-MS-App-Role. PlatformAuth promotes +// those to ctx.Identity. Same trust signal InternalAuth uses. +// 3. Local-dev bypass: when MS_INTERNAL_SECRET is unset AND we are NOT +// in Lambda, a synthetic admin identity is injected so +// `mirrorstack dev` (no tunnel) can render platform-scope routes +// without the developer wiring auth manually. +// +// In Lambda with no secret configured, this returns 503 (operator +// misconfiguration) to match InternalAuth's behavior. func PlatformAuth() func(http.Handler) http.Handler { + return platformAuth(lambdaenv.IsSet()) +} + +// platformAuth is the test seam; inLambda is injected so tests don't +// mutate process env captured at package init. +// +// Behavior matrix (secret = MS_INTERNAL_SECRET): +// +// inLambda + secret unset → 503 (operator misconfig; platform alerting) +// inLambda + secret set → enforce: validate secret + identity headers +// (Lambda authorizer path also still works — +// if Identity is preset in ctx, that wins) +// local + secret unset → BYPASS: inject synthetic admin identity +// (parallel to InternalAuth's local bypass) +// local + secret set → enforce (e.g. `mirrorstack dev --tunnel` +// where the CLI sets the secret) +func platformAuth(inLambda bool) func(http.Handler) http.Handler { + expected := os.Getenv("MS_INTERNAL_SECRET") + if expected == "" && !inLambda { + log.Printf("mirrorstack: MS_INTERNAL_SECRET not set; platform routes bypass auth with synthetic admin identity (local dev)") + } + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - a := Get(r.Context()) - if a == nil || a.AppRole == "" { + // Step 1: honor identity already attached upstream. + if existing := Get(r.Context()); existing != nil && existing.AppRole != "" { + next.ServeHTTP(w, r) + return + } + + // Step 2: local-dev bypass — inject synthetic admin. + if expected == "" && !inLambda { + ctx := Set(r.Context(), Identity{ + UserID: "local-dev-user", + AppID: "local-dev-app", + AppRole: RoleAdmin, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // Step 3: Lambda + no secret is a misconfig. + if expected == "" { + log.Printf("mirrorstack: platform auth rejected (no secret configured) from %s %s", r.RemoteAddr, r.URL.Path) + // SECURITY: generic body — same rationale as InternalAuth's 503. + httputil.JSON(w, http.StatusServiceUnavailable, httputil.ErrorResponse{ + Error: "service unavailable", + }) + return + } + + // Step 4: trusted-forwarder path. Validate secret, then read + // identity headers. + secret := r.Header.Get(HeaderInternalSecret) + if !constantTimeEqual(secret, expected) { + // SECURITY: never log the header value; only presence. + log.Printf("mirrorstack: platform auth rejected (secret mismatch, header_present=%v) from %s %s", secret != "", r.RemoteAddr, r.URL.Path) httputil.JSON(w, http.StatusUnauthorized, httputil.ErrorResponse{Error: "authentication required"}) return } - next.ServeHTTP(w, r) + + userID := r.Header.Get(HeaderUserID) + appID := r.Header.Get(HeaderAppID) + appRole := r.Header.Get(HeaderAppRole) + if userID == "" || appID == "" || appRole == "" { + httputil.JSON(w, http.StatusUnauthorized, httputil.ErrorResponse{Error: "platform identity headers required"}) + return + } + + ctx := Set(r.Context(), Identity{ + UserID: userID, + AppID: appID, + AppRole: appRole, + }) + next.ServeHTTP(w, r.WithContext(ctx)) }) } } diff --git a/auth/middleware_test.go b/auth/middleware_test.go index 816185e..96430d4 100644 --- a/auth/middleware_test.go +++ b/auth/middleware_test.go @@ -23,9 +23,68 @@ func requestWithRole(method, path, role string) *http.Request { return req } -func TestPlatformAuth_NoRole(t *testing.T) { - handler := PlatformAuth()(http.HandlerFunc(okHandler)) - req := httptest.NewRequest("GET", "/items", nil) +func TestPlatformAuth_AnyRoleInContextAllowed(t *testing.T) { + // Identity preset upstream (Lambda authorizer path) wins regardless of + // env state, so this test pins down "ctx wins" without caring about + // MS_INTERNAL_SECRET. Forces in-lambda + no secret to prove we honor + // preset Identity even when other branches would 503. + t.Setenv("MS_INTERNAL_SECRET", "") + handler := platformAuth(true)(http.HandlerFunc(okHandler)) + + for _, role := range []string{RoleAdmin, RoleMember, RoleViewer, "VideoManager"} { + req := requestWithRole("GET", "/items", role) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("role %q: expected 200, got %d", role, rec.Code) + } + } +} + +func TestPlatformAuth_LocalBypass_InjectsAdminIdentity(t *testing.T) { + // Local dev with no secret: synthetic admin identity is injected so + // /platform/* routes work in `mirrorstack dev` without tunnel. + t.Setenv("MS_INTERNAL_SECRET", "") + var seen *Identity + handler := platformAuth(false)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = Get(r.Context()) + })) + + req := httptest.NewRequest("GET", "/platform/users", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK && rec.Code != 0 { + t.Errorf("expected 200, got %d", rec.Code) + } + if seen == nil { + t.Fatal("expected synthetic Identity to be injected; got nil") + } + if seen.AppRole != RoleAdmin { + t.Errorf("synthetic identity must be admin so RequirePermission passes; got %q", seen.AppRole) + } +} + +func TestPlatformAuth_NoSecret_InLambda_503(t *testing.T) { + // Lambda + no secret = operator misconfig. Match InternalAuth's 503. + t.Setenv("MS_INTERNAL_SECRET", "") + handler := platformAuth(true)(http.HandlerFunc(okHandler)) + + req := httptest.NewRequest("GET", "/platform/users", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", rec.Code) + } +} + +func TestPlatformAuth_SecretSet_NoHeader_401(t *testing.T) { + t.Setenv("MS_INTERNAL_SECRET", "real-secret") + handler := platformAuth(false)(http.HandlerFunc(okHandler)) + + req := httptest.NewRequest("GET", "/platform/users", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -34,30 +93,65 @@ func TestPlatformAuth_NoRole(t *testing.T) { } } -func TestPlatformAuth_AnyRoleAllowed(t *testing.T) { - handler := PlatformAuth()(http.HandlerFunc(okHandler)) +func TestPlatformAuth_SecretSet_WrongSecret_401(t *testing.T) { + t.Setenv("MS_INTERNAL_SECRET", "real-secret") + handler := platformAuth(false)(http.HandlerFunc(okHandler)) - for _, role := range []string{RoleAdmin, RoleMember, RoleViewer} { - req := requestWithRole("GET", "/items", role) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) + req := httptest.NewRequest("GET", "/platform/users", nil) + req.Header.Set(HeaderInternalSecret, "wrong") + req.Header.Set(HeaderUserID, "u-1") + req.Header.Set(HeaderAppID, "a-1") + req.Header.Set(HeaderAppRole, RoleAdmin) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Errorf("role %q: expected 200, got %d", role, rec.Code) - } + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rec.Code) } } -func TestPlatformAuth_CustomRole(t *testing.T) { - handler := PlatformAuth()(http.HandlerFunc(okHandler)) +func TestPlatformAuth_SecretSet_MissingIdentityHeaders_401(t *testing.T) { + // Valid secret but no identity → 401. The trusted-forwarder must + // assert who the user is; we don't fabricate one for them. + t.Setenv("MS_INTERNAL_SECRET", "real-secret") + handler := platformAuth(false)(http.HandlerFunc(okHandler)) - // Custom role passes PlatformAuth (authentication gate — any non-empty role) - req := requestWithRole("GET", "/items", "VideoManager") + req := httptest.NewRequest("GET", "/platform/users", nil) + req.Header.Set(HeaderInternalSecret, "real-secret") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Errorf("expected 200 for custom role, got %d", rec.Code) + if rec.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "identity headers required") { + t.Errorf("expected explanatory body, got %q", rec.Body.String()) + } +} + +func TestPlatformAuth_SecretSet_ValidHeaders_InjectsIdentity(t *testing.T) { + t.Setenv("MS_INTERNAL_SECRET", "real-secret") + var seen *Identity + handler := platformAuth(false)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = Get(r.Context()) + })) + + req := httptest.NewRequest("GET", "/platform/users", nil) + req.Header.Set(HeaderInternalSecret, "real-secret") + req.Header.Set(HeaderUserID, "u-123") + req.Header.Set(HeaderAppID, "a-456") + req.Header.Set(HeaderAppRole, RoleViewer) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK && rec.Code != 0 { + t.Errorf("expected 200, got %d", rec.Code) + } + if seen == nil { + t.Fatal("expected Identity to be injected from headers; got nil") + } + if seen.UserID != "u-123" || seen.AppID != "a-456" || seen.AppRole != RoleViewer { + t.Errorf("identity mismatch: got %+v", seen) } } diff --git a/internal/core/module_test.go b/internal/core/module_test.go index b1f0eb2..2e2a8a5 100644 --- a/internal/core/module_test.go +++ b/internal/core/module_test.go @@ -217,7 +217,31 @@ func TestRouter(t *testing.T) { // --- Scope auth enforcement --- -func TestPlatform_RejectsNoAuth(t *testing.T) { +func TestPlatform_LocalDevBypass_InjectsSyntheticAdmin(t *testing.T) { + // Local dev + no MS_INTERNAL_SECRET: PlatformAuth injects a synthetic + // admin identity so `mirrorstack dev` (no tunnel) can render + // platform-scope routes without the developer wiring auth. Tunnel + // mode flips this branch off by setting the secret (see + // auth.platformAuth doc-matrix + mirrorstack-cli #33). + t.Setenv("MS_INTERNAL_SECRET", "") + m, _ := New(Config{ID: "test", Name: "Test"}) + m.Platform(func(r chi.Router) { + r.Get("/admin", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("admin")) + }) + }) + + rec := doRequest(t, m.Router(), "GET", "/admin") + if rec.Code != 200 { + t.Errorf("expected 200 (synthetic admin injected), got %d", rec.Code) + } +} + +func TestPlatform_SecretSet_RejectsNoHeader(t *testing.T) { + // When MS_INTERNAL_SECRET is set (tunnel mode or prod), local bypass + // is off — a Platform-scope route 401s without a valid trusted- + // forwarder header. + t.Setenv("MS_INTERNAL_SECRET", "real-secret") m, _ := New(Config{ID: "test", Name: "Test"}) m.Platform(func(r chi.Router) { r.Get("/admin", func(w http.ResponseWriter, r *http.Request) { @@ -227,7 +251,7 @@ func TestPlatform_RejectsNoAuth(t *testing.T) { rec := doRequest(t, m.Router(), "GET", "/admin") if rec.Code != 401 { - t.Errorf("expected 401 without auth, got %d", rec.Code) + t.Errorf("expected 401 without trusted-forwarder header, got %d", rec.Code) } }