Skip to content
Closed
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
15 changes: 11 additions & 4 deletions canvas/src/components/__tests__/ClaudeSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => unknown) =>
selector(mockCanvasState as Record<string, unknown>)
),
{ getState: () => mockCanvasState }
),
}));

vi.mock("../tabs/config/secrets-section", () => ({
Expand Down
11 changes: 9 additions & 2 deletions canvas/src/components/__tests__/tabs.a11y.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,16 @@ vi.mock("@/lib/api", () => ({
},
}));

const mockCanvasTabState = {
setPanelTab: vi.fn(),
};

vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
selector({ setPanelTab: vi.fn() })
useCanvasStore: Object.assign(
vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
selector(mockCanvasTabState as Record<string, unknown>)
),
{ getState: () => mockCanvasTabState }
),
summarizeWorkspaceCapabilities: vi.fn(() => ({ skills: [], tools: [] })),
}));
Expand Down
11 changes: 11 additions & 0 deletions workspace-server/internal/handlers/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
8 changes: 4 additions & 4 deletions workspace-server/internal/middleware/session_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -193,15 +193,15 @@ 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)
req.Header.Set("User-Agent", "molecule-tenant-platform/session-verifier")

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.
Expand Down
24 changes: 12 additions & 12 deletions workspace-server/internal/middleware/session_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 {
Expand All @@ -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.
Expand All @@ -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)
}
Expand All @@ -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")
}
Expand All @@ -99,15 +99,15 @@ 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)
}
if hits.Load() != 1 {
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())
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion workspace-server/internal/middleware/wsauth_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading