diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b4987..e2c619f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [v0.2.1] - 2026-06-13 + +A module can now read its own **trusted app id** on every guarded surface — including Public routes, which previously had no identity at all. + +### Added +- **`ms.AppID(ctx) string`** — the inbound twin of `ms.WithAppID`. Returns the app id from the request context's auth identity (`""` when none is set). This is the single **unspoofable** way a handler reads its own app: the SDK promotes the platform's trusted, dispatch-injected app id into the identity before the handler runs. Read this instead of pulling an app id off request data (query/body/path), which the caller controls and can forge. + +### Changed +- **The proxy guard (`auth.RequireProxy`) now promotes trusted app identity on its success path.** After the platform token validates — which proves the `X-MS-*` headers were injected by dispatch, not client-forged — the guard sets `auth.Identity` (`AppID`/`UserID`/`AppRole`) from those headers before the handler runs. This closes a gap on **Public** routes: they mount only the proxy guard (not `PlatformAuth`), so `auth.Get(ctx).AppID` was always empty there and a module could not read its own app. Promotion never happens on a path that has not validated the token (standalone/inert and rejected requests don't promote), and never clobbers an identity already set (e.g. Lambda's `InjectResources`). Mirrors the prod-Lambda asymmetry that `runtime.InjectResources` already closed. + ## [v0.2.0] - 2026-05-06 Phase 2 — module identity, prefix-aware schema resolution, and the cross-module data-routing contract. **Trust model: app owner is the trust root** for cross-module reads. The contributor declares nothing about who can read; the consumer declares what it wants from each dep; the catalog surfaces the pairing to the app owner at install time. Read-only by design — `GRANT SELECT` only, never write. Cross-module *writes* go through events or internal HTTP. diff --git a/VERSION b/VERSION index 0ea3a94..0c62199 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.2.1 diff --git a/auth/middleware.go b/auth/middleware.go index c387c2c..cee493d 100644 --- a/auth/middleware.go +++ b/auth/middleware.go @@ -268,7 +268,10 @@ func requireProxy(inLambda bool) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Lambda: headers already stripped + identity injected from payload. + // Lambda: headers already stripped + identity injected from payload + // (runtime.InjectResources). The payload is the trust boundary, so + // pass through WITHOUT promoting from headers — there are none to + // read, and the preset identity must not be touched. if inLambda { next.ServeHTTP(w, r) return @@ -276,7 +279,10 @@ func requireProxy(inLambda bool) func(http.Handler) http.Handler { expected, header, configured := readSecret() - // No token SOURCE configured: inert (standalone unit tests). + // No token SOURCE configured: inert (standalone unit tests). DO NOT + // promote identity here — nothing validated that the X-MS-* headers + // came from dispatch, so trusting them would let a direct caller + // forge an app id. The surface is simply open (local dev / tests). if !configured { next.ServeHTTP(w, r) return @@ -288,27 +294,54 @@ func requireProxy(inLambda bool) func(http.Handler) http.Handler { // reject rather than silently pass every request through. if expected == "" { log.Printf("mirrorstack: proxy guard rejected (token source configured but unreadable) from %s %s", r.RemoteAddr, r.URL.Path) - httputil.JSON(w, http.StatusForbidden, httputil.ErrorResponse{ - Error: "request did not come through the platform proxy", - Code: CodeNotProxied, - }) + rejectNotProxied(w) return } token := r.Header.Get(header) if !constantTimeEqual(token, expected) { log.Printf("mirrorstack: proxy guard rejected (token mismatch, header_present=%v) from %s %s", token != "", r.RemoteAddr, r.URL.Path) - httputil.JSON(w, http.StatusForbidden, httputil.ErrorResponse{ - Error: "request did not come through the platform proxy", - Code: CodeNotProxied, - }) + rejectNotProxied(w) return } - next.ServeHTTP(w, r) + + // SUCCESS PATH ONLY. The platform token validated, which proves the + // X-MS-* identity headers were injected by dispatch (the browser + // never holds the token, so a direct caller cannot forge a request + // that reaches here). Promote those now-trusted headers to + // auth.Identity so a Public/Platform handler can read its app via + // auth.Get(ctx).AppID — the single unspoofable source of app id. + // + // CRITICAL ORDERING: this MUST run only after the token check above + // passes. Promoting before validation would trust spoofable headers. + // + // Don't clobber an identity already on the context: if something + // upstream (e.g. PlatformAuth on the platform surface, or a future + // authorizer) already set one, that wins. + ctx := r.Context() + if Get(ctx) == nil { + ctx = Set(ctx, Identity{ + UserID: r.Header.Get(HeaderUserID), + AppID: r.Header.Get(HeaderAppID), + AppRole: r.Header.Get(HeaderAppRole), + }) + } + next.ServeHTTP(w, r.WithContext(ctx)) }) } } +// rejectNotProxied writes the standard 403 not_proxied response. Both failure +// paths in requireProxy (unreadable token source and token mismatch) produce +// the same JSON body; the log call before each site carries the distinct +// diagnostic. (Supersedes the dedup in PR #118.) +func rejectNotProxied(w http.ResponseWriter) { + httputil.JSON(w, http.StatusForbidden, httputil.ErrorResponse{ + Error: "request did not come through the platform proxy", + Code: CodeNotProxied, + }) +} + // secretReader returns the current secret, which header carries it, and // whether a secret SOURCE is configured at all. The file-backed variant // re-reads on every call so a refreshed token (CLI reconnect) is picked up diff --git a/auth/middleware_test.go b/auth/middleware_test.go index 9601bea..b2387ca 100644 --- a/auth/middleware_test.go +++ b/auth/middleware_test.go @@ -663,3 +663,121 @@ func TestRequireProxy_TokenFile_ReadError_NotMistakenForUnconfigured(t *testing. t.Fatal("SecretConfigured() must be true when MS_PLATFORM_TOKEN_FILE is set even if unreadable") } } + +// --- RequireProxy identity promotion (Public-route trusted app id) --- + +func TestRequireProxy_ValidToken_PromotesIdentity(t *testing.T) { + // The whole point of the #236 redesign: on a Public route, after the proxy + // token validates, the dispatch-injected X-MS-* headers are promoted to + // auth.Identity so the handler can read its TRUSTED app id via auth.Get — + // PlatformAuth doesn't run on Public, so without this promotion AppID is + // empty there. + t.Setenv("MS_PLATFORM_TOKEN", "real-token") + var seen *Identity + handler := requireProxy(false)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = Get(r.Context()) + })) + + req := httptest.NewRequest("GET", "/public/me", nil) + req.Header.Set(HeaderPlatformToken, "real-token") + req.Header.Set(HeaderUserID, "u-pub-1") + req.Header.Set(HeaderAppID, "a-pub-2") + req.Header.Set(HeaderAppRole, RoleViewer) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for valid proxied request, got %d", rec.Code) + } + if seen == nil { + t.Fatal("expected promoted Identity on the context; got nil") + } + if seen.AppID != "a-pub-2" { + t.Errorf("AppID = %q, want a-pub-2 (the trusted, dispatch-injected app id)", seen.AppID) + } + if seen.UserID != "u-pub-1" || seen.AppRole != RoleViewer { + t.Errorf("identity mismatch: got %+v", seen) + } +} + +func TestRequireProxy_RejectedRequest_NeverPromotes(t *testing.T) { + // A request rejected by the guard (no/wrong token) must NEVER reach the + // handler, so no identity is ever promoted from its (spoofable) headers. + t.Setenv("MS_PLATFORM_TOKEN", "real-token") + reached := false + handler := requireProxy(false)(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + reached = true + })) + + req := httptest.NewRequest("GET", "/public/me", nil) + // Attacker omits the token but tries to assert an app id directly. + req.Header.Set(HeaderAppID, "spoofed-app") + req.Header.Set(HeaderUserID, "spoofed-user") + req.Header.Set(HeaderAppRole, RoleAdmin) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", rec.Code) + } + if reached { + t.Error("handler must not run on a rejected request — promotion would trust spoofed headers") + } +} + +func TestRequireProxy_NoSecretInert_DoesNotPromote(t *testing.T) { + // Standalone `go test` (no token configured): the guard is inert and passes + // through, but it must NOT promote spoofable headers — nothing validated + // that they came from dispatch. Documented behavior: surface is open, app id + // unset (a module's own unit test injects identity explicitly if it needs + // one). + t.Setenv("MS_PLATFORM_TOKEN_FILE", "") + t.Setenv("MS_PLATFORM_TOKEN", "") + t.Setenv("MS_INTERNAL_SECRET", "") + var seen *Identity + handler := requireProxy(false)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = Get(r.Context()) + })) + + req := httptest.NewRequest("GET", "/public/me", nil) + req.Header.Set(HeaderAppID, "spoofed-app") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 (inert without configured token), got %d", rec.Code) + } + if seen != nil { + t.Errorf("inert guard must not promote unvalidated headers; got identity %+v", seen) + } +} + +func TestRequireProxy_PresetIdentity_NotClobbered(t *testing.T) { + // Lambda's InjectResources sets identity from the typed payload BEFORE any + // header promotion could run. The guard must never overwrite a preset + // identity with header values. Simulate by presetting on the context and + // sending conflicting headers on the validated-token path. + t.Setenv("MS_PLATFORM_TOKEN", "real-token") + var seen *Identity + handler := requireProxy(false)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = Get(r.Context()) + })) + + req := httptest.NewRequest("GET", "/public/me", nil) + req.Header.Set(HeaderPlatformToken, "real-token") + req.Header.Set(HeaderAppID, "header-app") + req = req.WithContext(Set(req.Context(), Identity{ + UserID: "preset-user", + AppID: "preset-app", + AppRole: RoleAdmin, + })) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if seen == nil || seen.AppID != "preset-app" { + t.Errorf("preset identity must win over headers; got %+v", seen) + } +} diff --git a/docs/concepts/scopes.md b/docs/concepts/scopes.md index ebd3135..6d638d3 100644 --- a/docs/concepts/scopes.md +++ b/docs/concepts/scopes.md @@ -10,9 +10,19 @@ Every HTTP route lives under one of three **scopes**. The scope determines which | **Public** | `ms.Public(fn)` | None | Anonymous (webhooks, OAuth callbacks, public APIs) | | **Internal** | `ms.Internal(fn)` | HMAC (`auth.InternalAuth`) | Platform itself (lifecycle, events, crons) | +## Reading your app id + +On **both** Platform and Public routes, read your module's app id from the trusted context identity — never from request data (query string, body, path), which the caller controls and can forge: + +```go +appID := ms.AppID(r.Context()) +``` + +The SDK promotes the platform's trusted, dispatch-injected app id into the context **before your handler runs**: on Platform via the session auth, on Public via the proxy guard's validated-token path (the guard proves the request came through dispatch, so the `X-MS-App-ID` header it forwards is trustworthy). `ms.AppID` returns it; it returns `""` only in a standalone unit test where no platform token is configured. `ms.AppID` is the inbound twin of `ms.WithAppID` (which *retargets* an outbound `ms.Call` at a different app). + ## Platform -Authenticated users of the host dashboard. The SDK checks a session token set by the platform's auth flow. Routes receive an `auth.Identity` via context with `AppID`, `UserID`, and `AppRole`. +Authenticated users of the host dashboard. The SDK checks a session token set by the platform's auth flow. Routes receive an `auth.Identity` via context with `AppID`, `UserID`, and `AppRole` — read the app id with `ms.AppID(r.Context())` (see above). ```go import p "github.com/mirrorstack-ai/app-module-sdk/roles" @@ -39,7 +49,7 @@ ms.Public(func(r chi.Router) { }) ``` -The SDK does not apply any auth here, but you are responsible for verifying payloads that claim identity (signed webhooks, OAuth state nonces, etc.). +The SDK does not run user auth here, but the proxy guard still fronts every Public route: a request that did not come through the platform proxy is rejected with `403 not_proxied`. Because the guard validated the proxy token, the app id it promotes (`ms.AppID(r.Context())`) is trusted — use it instead of reading an app id off the request. You are still responsible for verifying payloads that claim *user* identity (signed webhooks, OAuth state nonces, etc.). ## Internal diff --git a/docs/zh-TW/concepts/scopes.md b/docs/zh-TW/concepts/scopes.md index 7d6d272..0e7cd02 100644 --- a/docs/zh-TW/concepts/scopes.md +++ b/docs/zh-TW/concepts/scopes.md @@ -10,9 +10,19 @@ | **Public** | `ms.Public(fn)` | 無 | 匿名(webhooks、OAuth callbacks、公開 API) | | **Internal** | `ms.Internal(fn)` | HMAC(`auth.InternalAuth`) | 平台本身(lifecycle、events、crons) | +## 讀取自己的 app id + +在 Platform 與 Public route **兩者**,都從受信任的 context identity 讀取自己模組的 app id,**不要**從 request 資料(query string、body、path)讀 — 那些是呼叫者可控、可偽造的: + +```go +appID := ms.AppID(r.Context()) +``` + +SDK 會在你的 handler 執行**之前**,把平台經 dispatch 注入、受信任的 app id 提升進 context:Platform 透過 session auth,Public 透過 proxy guard 驗證過 token 的路徑(guard 證明 request 確實經過 dispatch,所以它轉發的 `X-MS-App-ID` 是可信的)。`ms.AppID` 會回傳它;只有在沒有設定 platform token 的獨立單元測試裡才回傳 `""`。`ms.AppID` 是 `ms.WithAppID` 的「入站對偶」(`ms.WithAppID` 用來把一個 *對外* 的 `ms.Call` *改指向* 另一個 app)。 + ## Platform -已登入的 dashboard 使用者。SDK 會檢查 platform auth flow 發出的 session token。Route 可以從 context 取得 `auth.Identity`,內含 `AppID`、`UserID`、`AppRole`。 +已登入的 dashboard 使用者。SDK 會檢查 platform auth flow 發出的 session token。Route 可以從 context 取得 `auth.Identity`,內含 `AppID`、`UserID`、`AppRole` — 用 `ms.AppID(r.Context())` 讀取 app id(見上)。 ```go ms.Platform(func(r chi.Router) { @@ -37,7 +47,7 @@ ms.Public(func(r chi.Router) { }) ``` -SDK 在這層不做任何認證,但如果 payload 裡宣稱了身分(signed webhook、OAuth state nonce 等),你必須自己驗證。 +SDK 在這層不做使用者認證,但 proxy guard 仍然守在每個 Public route 前面:沒有經過平台 proxy 的 request 會被以 `403 not_proxied` 拒絕。因為 guard 驗證過 proxy token,它提升出來的 app id(`ms.AppID(r.Context())`)是可信的 — 用它,不要從 request 讀 app id。但如果 payload 裡宣稱了 *使用者* 身分(signed webhook、OAuth state nonce 等),你還是必須自己驗證。 ## Internal diff --git a/internal/core/call.go b/internal/core/call.go index 9132c43..851b99e 100644 --- a/internal/core/call.go +++ b/internal/core/call.go @@ -134,3 +134,19 @@ func CallGet(ctx context.Context, targetModuleID, path string, out any) error { func CallPost(ctx context.Context, targetModuleID, path string, body, out any) error { return mustDefault("CallPost").CallPost(ctx, targetModuleID, path, body, out) } + +// AppID returns the app id from the request context's auth identity, or "" if +// no identity is set. It is the inbound twin of WithAppID and the single +// unspoofable way a handler reads its own app: the SDK promotes the trusted, +// dispatch-injected app id into the identity (Platform via PlatformAuth, Public +// via the proxy guard's success path; Lambda via runtime.InjectResources). +// Reading request data (query/body) for the app id instead is forgeable. +// +// Not module-bound (no *Module receiver) — identity lives on the context, so +// this works before Init and in tests. +func AppID(ctx context.Context) string { + if a := auth.Get(ctx); a != nil { + return a.AppID + } + return "" +} diff --git a/internal/core/module_test.go b/internal/core/module_test.go index 91a0ba7..15ae2d1 100644 --- a/internal/core/module_test.go +++ b/internal/core/module_test.go @@ -302,6 +302,37 @@ func TestPublic_ProxyGuard_AllowsProxiedCaller(t *testing.T) { } } +func TestPublic_ProxyGuard_PromotesTrustedAppID(t *testing.T) { + // The #236 gap closure end-to-end: a Public route mounts ONLY the proxy + // guard (no PlatformAuth), so before this change AppID(ctx) was always empty + // on Public. Now the guard's validated-token path promotes the dispatch- + // injected X-MS-App-ID into the identity, so the handler reads its trusted + // app via core.AppID — never from request data. + m := newTestModuleWithSecret(t, "test") // MS_INTERNAL_SECRET = "secret" + var gotAppID string + m.Public(func(r chi.Router) { + r.Get("/start", func(w http.ResponseWriter, r *http.Request) { + gotAppID = AppID(r.Context()) + _, _ = w.Write([]byte("started")) + }) + }) + + req := httptest.NewRequest("GET", "/public/start", nil) + req.Header.Set("X-MS-Internal-Secret", "secret") + req.Header.Set(auth.HeaderAppID, "trusted-app-42") + req.Header.Set(auth.HeaderUserID, "u-9") + req.Header.Set(auth.HeaderAppRole, auth.RoleMember) + rec := httptest.NewRecorder() + m.Router().ServeHTTP(rec, req) + + if rec.Code != 200 { + t.Fatalf("expected 200 for proxied caller, got %d", rec.Code) + } + if gotAppID != "trusted-app-42" { + t.Errorf("AppID(ctx) = %q on a Public route, want trusted-app-42 (promoted by the proxy guard)", gotAppID) + } +} + func TestPublic_ProxyGuard_InertWithoutToken(t *testing.T) { // Standalone module `go test` (no platform token configured): the guard is // inert so the public surface stays open for local unit tests. diff --git a/mirrorstack.go b/mirrorstack.go index 134ef27..1a9e646 100644 --- a/mirrorstack.go +++ b/mirrorstack.go @@ -188,15 +188,36 @@ func Emit(ctx context.Context, name string, payload any) error { return core.Emit(ctx, name, payload) } +// AppID returns the current app id from the request context, or "" if no +// identity is set. It is the inbound twin of WithAppID and the single +// unspoofable way a handler reads its OWN app. +// +// On every guarded surface the SDK promotes the platform's trusted, dispatch- +// injected app id into the context identity BEFORE the handler runs — Platform +// via PlatformAuth, Public via the proxy guard's validated-token path, and +// Lambda via the typed invoke payload (runtime.InjectResources). So a Public or +// Platform handler reads its app with: +// +// appID := ms.AppID(r.Context()) +// +// Do NOT read the app id from request data (query string, body, path) — those +// are caller-controlled and forgeable. ms.AppID is the trusted source. +func AppID(ctx context.Context) string { + return core.AppID(ctx) +} + // WithAppID returns a context whose inter-module Call scope is the given app, // overriding the ambient identity's app. ms.Call reads the app id from the // context (auth.Get) — for a handler that is the request's authenticated app, -// so authenticated callers need nothing extra. PUBLIC/proxy flows have no -// ambient identity (e.g. a sign-in proxy on ms.Public routes, where the target -// app arrives as request data, not as the caller's identity), so they set it -// explicitly: +// so authenticated callers need nothing extra. +// +// Use it to RETARGET an outbound ms.Call at a DIFFERENT app than the ambient +// one (the rare cross-app proxy case). To read your OWN app id, use ms.AppID — +// the SDK already promotes the request's trusted app into the identity, so a +// Public/Platform handler does not need WithAppID just to call within its own +// app: // -// ctx = ms.WithAppID(ctx, appID) +// ctx = ms.WithAppID(ctx, otherAppID) // ms.CallGet(ctx, providerModuleID, "/internal/authorize-url?"+q, &out) // // Any existing UserID/AppRole on the context is preserved; only AppID changes. diff --git a/withappid_test.go b/withappid_test.go index 52135e8..6066dbe 100644 --- a/withappid_test.go +++ b/withappid_test.go @@ -47,3 +47,29 @@ func TestWithAppID(t *testing.T) { } }) } + +func TestAppID(t *testing.T) { + t.Run("returns empty string when no identity is set", func(t *testing.T) { + if got := ms.AppID(context.Background()); got != "" { + t.Errorf("AppID = %q, want empty string", got) + } + }) + + t.Run("reads the app id from the context identity", func(t *testing.T) { + ctx := auth.Set(context.Background(), auth.Identity{ + UserID: "u-1", + AppID: "app-7", + AppRole: auth.RoleAdmin, + }) + if got := ms.AppID(ctx); got != "app-7" { + t.Errorf("AppID = %q, want app-7", got) + } + }) + + t.Run("is the inbound twin of WithAppID", func(t *testing.T) { + ctx := ms.WithAppID(context.Background(), "app-rt") + if got := ms.AppID(ctx); got != "app-rt" { + t.Errorf("AppID = %q, want app-rt", got) + } + }) +}