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
100 changes: 97 additions & 3 deletions auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
}
}
Expand Down
130 changes: 112 additions & 18 deletions auth/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
}
}

Expand Down
28 changes: 26 additions & 2 deletions internal/core/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
}

Expand Down
Loading