diff --git a/canvas/src/components/__tests__/ClaudeSettings.test.tsx b/canvas/src/components/__tests__/ClaudeSettings.test.tsx index ade36ac54..77f976126 100644 --- a/canvas/src/components/__tests__/ClaudeSettings.test.tsx +++ b/canvas/src/components/__tests__/ClaudeSettings.test.tsx @@ -19,11 +19,18 @@ vi.mock("@/lib/api", () => ({ api: { get: vi.fn(), put: vi.fn(), patch: vi.fn(), post: vi.fn() }, })); +const mockCanvasState = { + restartWorkspace: vi.fn(), + updateNodeData: vi.fn(), +}; + vi.mock("@/store/canvas", () => ({ - useCanvasStore: vi.fn(() => ({ - restartWorkspace: vi.fn(), - updateNodeData: vi.fn(), - })), + useCanvasStore: Object.assign( + vi.fn((selector: (s: Record) => unknown) => + selector(mockCanvasState as Record) + ), + { getState: () => mockCanvasState } + ), })); vi.mock("../tabs/config/secrets-section", () => ({ diff --git a/canvas/src/components/__tests__/tabs.a11y.test.tsx b/canvas/src/components/__tests__/tabs.a11y.test.tsx index 712555e06..91f2c3706 100644 --- a/canvas/src/components/__tests__/tabs.a11y.test.tsx +++ b/canvas/src/components/__tests__/tabs.a11y.test.tsx @@ -26,9 +26,16 @@ vi.mock("@/lib/api", () => ({ }, })); +const mockCanvasTabState = { + setPanelTab: vi.fn(), +}; + vi.mock("@/store/canvas", () => ({ - useCanvasStore: vi.fn((selector: (s: Record) => unknown) => - selector({ setPanelTab: vi.fn() }) + useCanvasStore: Object.assign( + vi.fn((selector: (s: Record) => unknown) => + selector(mockCanvasTabState as Record) + ), + { getState: () => mockCanvasTabState } ), summarizeWorkspaceCapabilities: vi.fn(() => ({ skills: [], tools: [] })), })); diff --git a/workspace-server/internal/handlers/discovery.go b/workspace-server/internal/handlers/discovery.go index bf55cc7d2..c221bc505 100644 --- a/workspace-server/internal/handlers/discovery.go +++ b/workspace-server/internal/handlers/discovery.go @@ -348,6 +348,17 @@ func validateDiscoveryCaller(ctx context.Context, c *gin.Context, workspaceID st tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization")) if tok == "" { + // Canvas hits this endpoint via session cookie, not bearer token. + // Add verifiedCPSession() as a fallback after the bearer check so + // SaaS canvas Peers tab doesn't 401. Self-hosted workspaces are + // unaffected — they have no CP session cookie. + if ok, presented := middleware.VerifiedCPSession(c.GetHeader("Cookie")); ok { + return nil + } + if presented { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"}) + return errors.New("invalid session") + } c.JSON(http.StatusUnauthorized, gin.H{"error": "missing workspace auth token"}) return errors.New("missing token") } diff --git a/workspace-server/internal/middleware/session_auth.go b/workspace-server/internal/middleware/session_auth.go index 359e540d3..225a8b23f 100644 --- a/workspace-server/internal/middleware/session_auth.go +++ b/workspace-server/internal/middleware/session_auth.go @@ -157,7 +157,7 @@ func tenantSlug() string { return strings.TrimSpace(os.Getenv("MOLECULE_ORG_SLUG")) } -// verifiedCPSession returns true when the request carries a cookie +// VerifiedCPSession returns true when the request carries a cookie // that the CP confirms belongs to a MEMBER of THIS tenant's org (not // just "someone is logged in"). The difference is the authz boundary: // any WorkOS-authed user could hit /cp/auth/me successfully; only @@ -171,7 +171,7 @@ func tenantSlug() string { // — fail-safe: better to refuse session auth than to accept it // without knowing which tenant we ARE. Deployments that want session // auth MUST set both CP_UPSTREAM_URL and MOLECULE_ORG_SLUG. -func verifiedCPSession(cookieHeader string) (valid, presented bool) { +func VerifiedCPSession(cookieHeader string) (valid, presented bool) { if cookieHeader == "" { return false, false } @@ -193,7 +193,7 @@ func verifiedCPSession(cookieHeader string) (valid, presented bool) { client := &http.Client{Timeout: 3 * time.Second} req, err := http.NewRequest("GET", verifyURL, nil) if err != nil { - log.Printf("verifiedCPSession: build req: %v", err) + log.Printf("VerifiedCPSession: build req: %v", err) return false, true } req.Header.Set("Cookie", cookieHeader) @@ -201,7 +201,7 @@ func verifiedCPSession(cookieHeader string) (valid, presented bool) { resp, err := client.Do(req) if err != nil { - log.Printf("verifiedCPSession: upstream: %v", err) + log.Printf("VerifiedCPSession: upstream: %v", err) // NOTE: we deliberately do NOT cache transport failures. // Caching them would mean a 3s CP blip locks out all users // for the negative-TTL window. Next request retries. diff --git a/workspace-server/internal/middleware/session_auth_test.go b/workspace-server/internal/middleware/session_auth_test.go index b60cc7f74..6e6e9a087 100644 --- a/workspace-server/internal/middleware/session_auth_test.go +++ b/workspace-server/internal/middleware/session_auth_test.go @@ -37,7 +37,7 @@ func mockCPServer(t *testing.T, status int, body string) (*httptest.Server, *ato func TestVerifiedCPSession_EmptyCookie(t *testing.T) { resetSessionCache() - ok, presented := verifiedCPSession("") + ok, presented := VerifiedCPSession("") if ok || presented { t.Errorf("empty cookie should be (false, false); got (%v, %v)", ok, presented) } @@ -47,7 +47,7 @@ func TestVerifiedCPSession_NoSlugConfigured(t *testing.T) { resetSessionCache() t.Setenv("CP_UPSTREAM_URL", "https://cp.test") t.Setenv("MOLECULE_ORG_SLUG", "") - ok, presented := verifiedCPSession("session=foo") + ok, presented := VerifiedCPSession("session=foo") // Without a slug we can't ask about tenant membership. Must // refuse (false, false) — caller falls through to bearer tier. if ok || presented { @@ -59,7 +59,7 @@ func TestVerifiedCPSession_NoCPConfigured(t *testing.T) { resetSessionCache() t.Setenv("CP_UPSTREAM_URL", "") t.Setenv("MOLECULE_ORG_SLUG", "acme") - ok, presented := verifiedCPSession("session=foo") + ok, presented := VerifiedCPSession("session=foo") // Self-hosted path: CP not configured, but cookie WAS presented. // Presented=true lets the caller know not to fall through to // bearer as if no credential arrived. @@ -74,7 +74,7 @@ func TestVerifiedCPSession_MemberTrue(t *testing.T) { t.Setenv("CP_UPSTREAM_URL", srv.URL) t.Setenv("MOLECULE_ORG_SLUG", "acme") - ok, presented := verifiedCPSession("session=valid") + ok, presented := VerifiedCPSession("session=valid") if !ok || !presented { t.Errorf("valid member should be (true, true); got (%v, %v)", ok, presented) } @@ -83,7 +83,7 @@ func TestVerifiedCPSession_MemberTrue(t *testing.T) { } // Second call must be served from cache. - ok, _ = verifiedCPSession("session=valid") + ok, _ = VerifiedCPSession("session=valid") if !ok { t.Errorf("cached call should still be true") } @@ -99,7 +99,7 @@ func TestVerifiedCPSession_MemberFalse(t *testing.T) { t.Setenv("CP_UPSTREAM_URL", srv.URL) t.Setenv("MOLECULE_ORG_SLUG", "acme") - ok, presented := verifiedCPSession("session=wrong-tenant") + ok, presented := VerifiedCPSession("session=wrong-tenant") if ok || !presented { t.Errorf("non-member should be (false, true); got (%v, %v)", ok, presented) } @@ -107,7 +107,7 @@ func TestVerifiedCPSession_MemberFalse(t *testing.T) { t.Fatalf("expected 1 upstream hit") } // Cached negatively. - _, _ = verifiedCPSession("session=wrong-tenant") + _, _ = VerifiedCPSession("session=wrong-tenant") if hits.Load() != 1 { t.Errorf("negative result should cache too; got %d hits", hits.Load()) } @@ -119,7 +119,7 @@ func TestVerifiedCPSession_Upstream401(t *testing.T) { t.Setenv("CP_UPSTREAM_URL", srv.URL) t.Setenv("MOLECULE_ORG_SLUG", "acme") - ok, presented := verifiedCPSession("session=expired") + ok, presented := VerifiedCPSession("session=expired") if ok || !presented { t.Errorf("401 upstream should be (false, true); got (%v, %v)", ok, presented) } @@ -131,7 +131,7 @@ func TestVerifiedCPSession_MalformedJSON(t *testing.T) { t.Setenv("CP_UPSTREAM_URL", srv.URL) t.Setenv("MOLECULE_ORG_SLUG", "acme") - ok, presented := verifiedCPSession("session=broken") + ok, presented := VerifiedCPSession("session=broken") if ok || !presented { t.Errorf("malformed body should be (false, true); got (%v, %v)", ok, presented) } @@ -143,7 +143,7 @@ func TestVerifiedCPSession_TransportErrorNotCached(t *testing.T) { t.Setenv("CP_UPSTREAM_URL", "http://127.0.0.1:1") t.Setenv("MOLECULE_ORG_SLUG", "acme") - ok, presented := verifiedCPSession("session=whatever") + ok, presented := VerifiedCPSession("session=whatever") if ok || !presented { t.Errorf("transport error should be (false, true); got (%v, %v)", ok, presented) } @@ -178,12 +178,12 @@ func TestVerifiedCPSession_CrossTenantIsolation(t *testing.T) { cookie := "session=shared-auth" t.Setenv("MOLECULE_ORG_SLUG", "acme") - if ok, _ := verifiedCPSession(cookie); !ok { + if ok, _ := VerifiedCPSession(cookie); !ok { t.Errorf("acme should say member=true") } t.Setenv("MOLECULE_ORG_SLUG", "bob") - if ok, _ := verifiedCPSession(cookie); ok { + if ok, _ := VerifiedCPSession(cookie); ok { t.Errorf("bob tenant must NOT accept acme cookie despite same session bytes") } if len(reqs) != 2 { diff --git a/workspace-server/internal/middleware/wsauth_middleware.go b/workspace-server/internal/middleware/wsauth_middleware.go index 66b8f261d..a391fda35 100644 --- a/workspace-server/internal/middleware/wsauth_middleware.go +++ b/workspace-server/internal/middleware/wsauth_middleware.go @@ -174,7 +174,7 @@ func AdminAuth(database *sql.DB) gin.HandlerFunc { // hosted / dev deploys without a CP fall through to the // bearer-only path unchanged. if cookieHeader := c.GetHeader("Cookie"); cookieHeader != "" { - if ok, _ := verifiedCPSession(cookieHeader); ok { + if ok, _ := VerifiedCPSession(cookieHeader); ok { c.Next() return }