Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd8b357fc2 |
+29
-47
@@ -73,15 +73,7 @@ else
|
||||
fi
|
||||
|
||||
# Test 4: Create workspace B (needs bearer — tokens now exist in DB)
|
||||
# #1953 cross-tenant isolation: Summarizer is created as a CHILD of Echo so the
|
||||
# two live in the SAME org (Echo is the org root; Summarizer hangs off it via
|
||||
# parent_id). The peer-discovery tests below assert same-org peer enumeration
|
||||
# (Echo sees its child, the child sees its parent). Previously both were created
|
||||
# parent_id=NULL — two DISTINCT org roots — and "peers" only listed each other
|
||||
# via the `WHERE parent_id IS NULL` branch that returned every tenant's org root.
|
||||
# That branch WAS the cross-tenant leak (#1953) and is now removed, so two org
|
||||
# roots no longer see each other; the assertions must run inside one org.
|
||||
R=$(acurl -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d "{\"name\":\"Summarizer Agent\",\"tier\":1,\"runtime\":\"external\",\"external\":true,\"parent_id\":\"$ECHO_ID\"}")
|
||||
R=$(acurl -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Summarizer Agent","tier":1,"runtime":"external","external":true}')
|
||||
check "POST /workspaces (create summarizer)" '"status":"awaiting_agent"' "$R"
|
||||
SUM_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
|
||||
@@ -141,23 +133,21 @@ check "Heartbeat updated uptime" '"uptime_seconds":120' "$R"
|
||||
R=$(curl -s "$BASE/registry/discover/$ECHO_ID")
|
||||
check "GET /registry/discover/:id (missing caller rejected)" 'X-Workspace-ID header is required' "$R"
|
||||
|
||||
# Test 12: Discover (from same-org child — allowed)
|
||||
# Test 12: Discover (from sibling — allowed)
|
||||
R=$(curl -s "$BASE/registry/discover/$ECHO_ID" -H "X-Workspace-ID: $SUM_ID" -H "Authorization: Bearer $SUM_TOKEN")
|
||||
check "GET /registry/discover/:id (same-org)" '"url"' "$R"
|
||||
check "GET /registry/discover/:id (sibling)" '"url"' "$R"
|
||||
|
||||
# Test 13: Peers — same-org parent/child see each other (#1953). Echo is the org
|
||||
# root and lists its child Summarizer; Summarizer lists its parent Echo. A
|
||||
# cross-org workspace would NOT appear here (see cross_tenant_isolation_test.go).
|
||||
# Test 13: Peers (root siblings see each other)
|
||||
R=$(curl -s "$BASE/registry/$ECHO_ID/peers" -H "Authorization: Bearer $ECHO_TOKEN")
|
||||
check "GET /registry/:id/peers (has summarizer)" '"Summarizer' "$R"
|
||||
|
||||
R=$(curl -s "$BASE/registry/$SUM_ID/peers" -H "Authorization: Bearer $SUM_TOKEN")
|
||||
check "GET /registry/:id/peers (has echo)" '"Echo Agent"' "$R"
|
||||
|
||||
# Test 14: Check access (same-org parent↔child — allowed)
|
||||
# Test 14: Check access (root siblings)
|
||||
R=$(curl -s -X POST "$BASE/registry/check-access" -H "Content-Type: application/json" \
|
||||
-d "{\"caller_id\":\"$ECHO_ID\",\"target_id\":\"$SUM_ID\"}")
|
||||
check "POST /registry/check-access (same-org allowed)" '"allowed":true' "$R"
|
||||
check "POST /registry/check-access (siblings allowed)" '"allowed":true' "$R"
|
||||
|
||||
# Test 15: PATCH workspace (update position)
|
||||
R=$(acurl -X PATCH "$BASE/workspaces/$ECHO_ID" -H "Content-Type: application/json" -d '{"x":100,"y":200}')
|
||||
@@ -299,40 +289,32 @@ R=$(curl -s "$BASE/workspaces" -H "Authorization: Bearer $ECHO_TOKEN")
|
||||
check "current_task in list response" '"current_task"' "$R"
|
||||
|
||||
# Test 21: Delete
|
||||
# #1953: Summarizer is now a CHILD of Echo (same-org, for the peer-discovery
|
||||
# tests above). DELETE on the *parent* (Echo) cascade-removes its descendants
|
||||
# (CascadeDelete walks the recursive `parent_id` CTE), so deleting Echo first
|
||||
# would also remove Summarizer and the "one survives" assertion would see 0.
|
||||
# Delete the CHILD (Summarizer) here instead: a child delete does NOT cascade
|
||||
# upward, so the parent Echo survives and count=1 holds. The bundle round-trip
|
||||
# below needs Summarizer's exported config, so capture it BEFORE this delete.
|
||||
BUNDLE=$(curl -s "$BASE/bundles/export/$SUM_ID" -H "Authorization: Bearer $SUM_TOKEN")
|
||||
check "GET /bundles/export/:id" '"name":"Summarizer Agent"' "$BUNDLE"
|
||||
ORIG_NAME=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.stdin)['name'])")
|
||||
ORIG_TIER=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.stdin)['tier'])")
|
||||
|
||||
R=$(acurl -X DELETE "$BASE/workspaces/$SUM_ID?confirm=true" \
|
||||
-H "Authorization: Bearer $SUM_TOKEN" \
|
||||
-H "X-Confirm-Name: Summarizer Agent")
|
||||
check "DELETE /workspaces/:id" '"status":"removed"' "$R"
|
||||
|
||||
# Parent Echo must survive a child delete — list as Echo and expect count=1.
|
||||
R=$(curl -s "$BASE/workspaces" -H "Authorization: Bearer $ECHO_TOKEN")
|
||||
COUNT=$(echo "$R" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
|
||||
check "List after delete (count=1)" "1" "$COUNT"
|
||||
|
||||
# Test 22: Bundle round-trip — export → delete → import → verify same config.
|
||||
# Summarizer's bundle was captured above; now delete the parent Echo (the only
|
||||
# remaining workspace) so the import lands in a clean org, then re-import the
|
||||
# Summarizer bundle.
|
||||
echo ""
|
||||
echo "--- Bundle Round-Trip Test ---"
|
||||
|
||||
# Delete the remaining parent Echo — use ECHO_TOKEN (per-workspace) for
|
||||
# WorkspaceAuth and ADMIN_TOKEN for the AdminAuth layer.
|
||||
R=$(acurl -X DELETE "$BASE/workspaces/$ECHO_ID?confirm=true" \
|
||||
-H "Authorization: Bearer $ECHO_TOKEN" \
|
||||
-H "X-Confirm-Name: Echo Agent v2")
|
||||
check "DELETE /workspaces/:id" '"status":"removed"' "$R"
|
||||
|
||||
R=$(curl -s "$BASE/workspaces" -H "Authorization: Bearer $SUM_TOKEN")
|
||||
COUNT=$(echo "$R" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
|
||||
check "List after delete (count=1)" "1" "$COUNT"
|
||||
|
||||
# Test 22: Bundle round-trip — export → delete → import → verify same config
|
||||
echo ""
|
||||
echo "--- Bundle Round-Trip Test ---"
|
||||
|
||||
# Export the summarizer workspace (#165 / PR #167 — admin-gated)
|
||||
BUNDLE=$(curl -s "$BASE/bundles/export/$SUM_ID" -H "Authorization: Bearer $SUM_TOKEN")
|
||||
check "GET /bundles/export/:id" '"name":"Summarizer Agent"' "$BUNDLE"
|
||||
|
||||
# Capture original config for comparison
|
||||
ORIG_NAME=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.stdin)['name'])")
|
||||
ORIG_TIER=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.stdin)['tier'])")
|
||||
|
||||
# Delete the workspace — use SUM_TOKEN (per-workspace) for WorkspaceAuth
|
||||
# and ADMIN_TOKEN for the AdminAuth layer.
|
||||
R=$(curl -s -X DELETE "$BASE/workspaces/$SUM_ID?confirm=true" \
|
||||
-H "Authorization: Bearer $SUM_TOKEN" \
|
||||
-H "X-Confirm-Name: Summarizer Agent")
|
||||
check "Delete before re-import" '"status":"removed"' "$R"
|
||||
|
||||
# After deleting both workspaces, all per-workspace tokens are revoked.
|
||||
|
||||
@@ -375,30 +375,6 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
|
||||
Response: gin.H{"error": "access denied: workspaces cannot communicate per hierarchy rules"},
|
||||
}
|
||||
}
|
||||
|
||||
// #1953 cross-tenant isolation. CanCommunicate alone does NOT enforce
|
||||
// org boundaries: its "root-level siblings — both have no parent" rule
|
||||
// treats every tenant's org root as a sibling, so a caller that is an
|
||||
// org root could resolve and route a2a to another tenant's org root
|
||||
// (and resolveAgentURL accepts ANY workspace id with no org check).
|
||||
// Gate on the SAME parent_id-chain org scoping the OFFSEC-015 broadcast
|
||||
// fix uses: reject before resolveAgentURL when caller and target are in
|
||||
// different orgs. Fail-closed — a DB error denies cross-org routing.
|
||||
ok, err := sameOrg(ctx, db.DB, callerID, workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("ProxyA2A: org-scope check failed %s → %s: %v — denying", callerID, workspaceID, err)
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusForbidden,
|
||||
Response: gin.H{"error": "access denied: org isolation check failed"},
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
log.Printf("ProxyA2A: cross-org routing denied %s → %s (#1953)", callerID, workspaceID)
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusForbidden,
|
||||
Response: gin.H{"error": "access denied: target workspace is in a different org"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Budget enforcement: reject A2A calls when the workspace has exceeded its
|
||||
|
||||
@@ -437,10 +437,6 @@ func TestProxyA2A_CallerIDPropagated(t *testing.T) {
|
||||
WithArgs("ws-target").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-target", "ws-parent"))
|
||||
|
||||
// #1953 cross-tenant guard: same-org check after CanCommunicate. Both
|
||||
// workspaces resolve to the same org root → routing allowed.
|
||||
mockSameOrg(mock, "ws-caller", "ws-target", true)
|
||||
|
||||
expectBudgetCheck(mock, "ws-target")
|
||||
|
||||
// Expect activity log with source_id set
|
||||
@@ -469,24 +465,6 @@ func TestProxyA2A_CallerIDPropagated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// mockSameOrg sets up the two org-root recursive-CTE expectations that the
|
||||
// #1953 cross-tenant guard in proxyA2ARequest runs after CanCommunicate passes.
|
||||
// sameOrg=true returns the SAME root_id for both caller and target (same tenant);
|
||||
// sameOrg=false returns different root_ids (cross-tenant → routing must be denied).
|
||||
func mockSameOrg(mock sqlmock.Sqlmock, caller, target string, sameOrg bool) {
|
||||
callerRoot := "org-root-shared"
|
||||
targetRoot := "org-root-shared"
|
||||
if !sameOrg {
|
||||
targetRoot = "org-root-other-tenant"
|
||||
}
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(callerRoot))
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS").
|
||||
WithArgs(target).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(targetRoot))
|
||||
}
|
||||
|
||||
// mockCanCommunicate sets up sqlmock expectations for CanCommunicate(caller, target).
|
||||
// allowed=true sets up rows that satisfy the access policy (siblings under same parent).
|
||||
// allowed=false sets up rows that don't (different parents).
|
||||
@@ -681,9 +659,6 @@ func TestProxyA2A_CallerIDDerivedFromBearer(t *testing.T) {
|
||||
WithArgs("ws-target").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-target", "ws-parent"))
|
||||
|
||||
// 3b. #1953 cross-tenant guard — same org root → routing allowed.
|
||||
mockSameOrg(mock, "ws-caller", "ws-target", true)
|
||||
|
||||
expectBudgetCheck(mock, "ws-target")
|
||||
|
||||
// 4. activity_logs INSERT — verify source_id arg is the derived ws-caller
|
||||
@@ -2469,6 +2444,94 @@ func TestProxyA2A_PollMode_CanvasUserWithVerifiedSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyA2A_PollMode_CanvasUserCallerID_PropagatesToActivityLog pins
|
||||
// the specific contract that broke in molecule-core#1675 (2026-05-22):
|
||||
// canvas chat messages from a user with an identity workspace (RFC#637
|
||||
// canvas-user-identity rollout) MUST write an activity_logs row whose
|
||||
// source_id matches the canvas user's workspace UUID, NOT NULL so the
|
||||
// channel plugin's poll path can deliver them as `<channel kind="canvas_user">`
|
||||
// tags to the bound Claude Code session, AND the canvas chat-history can
|
||||
// re-render the user's own message on reopen.
|
||||
//
|
||||
// The sibling test TestProxyA2A_PollMode_CanvasUserWithVerifiedSession
|
||||
// covers the verified-session cookie path. THIS test covers the admin-token
|
||||
// path (molecli / break-glass) which also classifies as canvas-user and
|
||||
// bypasses CanCommunicate.
|
||||
func TestProxyA2A_PollMode_CanvasUserCallerID_PropagatesToActivityLog(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
const targetWS = "ws-canvas-target-1675"
|
||||
const canvasUserWS = "344a2623-50bf-4ab9-9732-220779305c8f" // shape from #1675 evidence
|
||||
|
||||
// isGenuineCanvasUser checks ADMIN_TOKEN first, so HasAnyLiveToken is
|
||||
// never reached. No SELECT COUNT(*) expectation needed.
|
||||
expectBudgetCheck(mock, targetWS)
|
||||
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
|
||||
WithArgs(targetWS).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode"}).AddRow("poll"))
|
||||
|
||||
// logA2AReceiveQueued looks up the workspace name for the summary.
|
||||
mock.ExpectQuery("SELECT name FROM workspaces WHERE id").
|
||||
WithArgs(targetWS).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Canvas Target"))
|
||||
|
||||
// CRITICAL: the activity_logs INSERT MUST happen, and its source_id
|
||||
// argument MUST match the canvas user's workspace UUID. The previous
|
||||
// behaviour (sqlmock.ExpectExec with no WithArgs) accepted any args
|
||||
// which is exactly how the regression in #1675 escaped CI: the INSERT
|
||||
// fired, but with source_id=NULL because callerID propagation was
|
||||
// bypassed somewhere upstream. Pin the source_id position explicitly.
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(
|
||||
targetWS, // workspace_id
|
||||
"a2a_receive", // activity_type
|
||||
canvasUserWS, // source_id (NOT NULL the contract this test exists to pin)
|
||||
targetWS, // target_id
|
||||
"message/send", // method
|
||||
sqlmock.AnyArg(), // summary
|
||||
sqlmock.AnyArg(), // request_body
|
||||
sqlmock.AnyArg(), // response_body (nil for queued)
|
||||
sqlmock.AnyArg(), // tool_trace
|
||||
sqlmock.AnyArg(), // duration_ms
|
||||
"ok", // status
|
||||
sqlmock.AnyArg(), // error_detail
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: targetWS}}
|
||||
// X-Workspace-ID is the canonical way canvas Next.js identifies the
|
||||
// signed-in user's identity workspace to the platform (per RFC#637).
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+targetWS+"/a2a",
|
||||
bytes.NewBufferString(`{"jsonrpc":"2.0","id":"canvas-1","method":"message/send","params":{"message":{"role":"user","parts":[{"text":"hello from canvas"}]}}}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("X-Workspace-ID", canvasUserWS)
|
||||
c.Request.Header.Set("Authorization", "Bearer test-admin-secret-1675")
|
||||
|
||||
t.Setenv("ADMIN_TOKEN", "test-admin-secret-1675")
|
||||
|
||||
handler.ProxyA2A(c)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 queued, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if resp["status"] != "queued" {
|
||||
t.Errorf("response.status = %v, want %q", resp["status"], "queued")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations the activity INSERT may have been skipped OR fired with a different source_id (the #1675 regression shape): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyA2A_ForgedSameOrigin_CannotBypassCanCommunicate is the security
|
||||
// crux of the #1673 fix and the reason PR #1944 was held. In the combined-
|
||||
// tenant SaaS image (CANVAS_PROXY_URL set, CP session verification configured),
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// cross_tenant_isolation_test.go — #1953 regression tests.
|
||||
//
|
||||
// Three workspace-server paths historically derived an "org-root sibling set"
|
||||
// as `WHERE parent_id IS NULL`, which matches EVERY tenant's org root (the
|
||||
// workspaces table has no org_id column) → cross-tenant data exposure:
|
||||
//
|
||||
// 1. GET /registry/:id/peers (discovery.Peers)
|
||||
// 2. MCP toolListPeers (mcp_tools.toolListPeers)
|
||||
// 3. a2a routing (a2a_proxy.proxyA2ARequest → resolveAgentURL)
|
||||
//
|
||||
// These tests assert that a workspace in a DIFFERENT org is never returned as a
|
||||
// peer and that a2a refuses to resolve/route to a workspace outside the caller's
|
||||
// org, while same-org peers/targets still work. They reuse the SAME parent_id-
|
||||
// chain org scoping the OFFSEC-015 broadcast fix introduced (org_scope.go).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// dbHandleForTest returns the global sqlmock-backed *sql.DB that setupTestDB
|
||||
// installs, for tests that need to hand a *sql.DB to a component (e.g.
|
||||
// MCPHandler.database, sameOrg) rather than relying on the package-global.
|
||||
func dbHandleForTest() *sql.DB { return db.DB }
|
||||
|
||||
// peerColsForIsolation matches queryPeerMaps' SELECT column set.
|
||||
var peerColsForIsolation = []string{
|
||||
"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks",
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Path 1: GET /registry/:id/peers — discovery.Peers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// TestPeers_CrossTenant_OrgRootNotLeaked is the core #1953 regression for the
|
||||
// discovery path. The caller is an org root (parent_id IS NULL). Pre-fix the
|
||||
// handler ran `SELECT ... WHERE w.parent_id IS NULL AND w.id != $1`, returning
|
||||
// every OTHER tenant's org root as a "sibling" peer. Post-fix an org-root caller
|
||||
// issues NO sibling query — its only peers are its own children. If the handler
|
||||
// regressed and issued the cross-tenant sibling query, sqlmock would report an
|
||||
// unexpected query (the expectation below is intentionally NOT registered) and
|
||||
// the test fails.
|
||||
func TestPeers_CrossTenant_OrgRootNotLeaked(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewDiscoveryHandler()
|
||||
|
||||
// Behavioural leak test: register the OLD leaky `parent_id IS NULL` sibling
|
||||
// query so that IF the handler still issues it, it returns another tenant's
|
||||
// org root (org-b-root). The fix removes that query for an org-root caller,
|
||||
// so org-b-root must never appear in the output. Unordered matching makes
|
||||
// the leaky-sibling expectation optional — the fix simply never consumes it.
|
||||
mock.MatchExpectationsInOrder(false)
|
||||
|
||||
caller := "org-a-root" // parent_id IS NULL — an org root for tenant A
|
||||
|
||||
// parent_id lookup → NULL (caller is an org root)
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil))
|
||||
|
||||
// LEAKY sibling query (pre-fix). Returns a DIFFERENT tenant's org root.
|
||||
// The fix must NOT issue this query; if it does, org-b-root leaks into the
|
||||
// peer list and the output assertion below fails.
|
||||
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id IS NULL AND w.id != \\$1").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows(peerColsForIsolation).
|
||||
AddRow("org-b-root", "Org B Root", "lead", 0, "online", []byte("null"), "http://b-root", nil, 0))
|
||||
|
||||
// Children query — caller's own org-A children only. Return one child.
|
||||
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.id != \\$2").
|
||||
WithArgs(caller, caller).
|
||||
WillReturnRows(sqlmock.NewRows(peerColsForIsolation).
|
||||
AddRow("org-a-child", "Org A Child", "worker", 1, "online", []byte("null"), "http://a-child", caller, 0))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: caller}}
|
||||
c.Request = httptest.NewRequest("GET", "/registry/"+caller+"/peers", nil)
|
||||
|
||||
handler.Peers(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var peers []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &peers); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
// The other-tenant org root must NEVER appear; only the same-org child.
|
||||
for _, p := range peers {
|
||||
if id, _ := p["id"].(string); id == "org-b-root" {
|
||||
t.Fatalf("cross-tenant leak (#1953): org-b-root appeared in org-a-root's peer list: %v", peers)
|
||||
}
|
||||
}
|
||||
if len(peers) != 1 {
|
||||
t.Fatalf("expected exactly 1 peer (same-org child), got %d: %v", len(peers), peers)
|
||||
}
|
||||
// NOTE: ExpectationsWereMet is intentionally NOT asserted — the leaky
|
||||
// sibling expectation is deliberately left unconsumed by the fixed path.
|
||||
}
|
||||
|
||||
// TestPeers_SameOrg_SiblingsStillWork is the positive companion: a non-root
|
||||
// child caller still sees its same-org siblings, children, and parent. This
|
||||
// guards against the fix over-scoping and breaking legitimate intra-org
|
||||
// discovery.
|
||||
func TestPeers_SameOrg_SiblingsStillWork(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewDiscoveryHandler()
|
||||
|
||||
caller := "org-a-child-1"
|
||||
parent := "org-a-root"
|
||||
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(parent))
|
||||
|
||||
// Siblings — scoped to the shared parent (one tenant).
|
||||
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.id != \\$2").
|
||||
WithArgs(parent, caller).
|
||||
WillReturnRows(sqlmock.NewRows(peerColsForIsolation).
|
||||
AddRow("org-a-child-2", "Org A Sibling", "worker", 1, "online", []byte("null"), "http://a-sib", parent, 0))
|
||||
|
||||
// Children — none.
|
||||
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.id != \\$2 AND w.status").
|
||||
WithArgs(caller, caller).
|
||||
WillReturnRows(sqlmock.NewRows(peerColsForIsolation))
|
||||
|
||||
// Parent.
|
||||
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.id = \\$1 AND w.id != \\$2 AND w.status").
|
||||
WithArgs(parent, caller).
|
||||
WillReturnRows(sqlmock.NewRows(peerColsForIsolation).
|
||||
AddRow(parent, "Org A Root", "lead", 0, "online", []byte("null"), "http://a-root", nil, 0))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: caller}}
|
||||
c.Request = httptest.NewRequest("GET", "/registry/"+caller+"/peers", nil)
|
||||
|
||||
handler.Peers(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var peers []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &peers); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
// Sibling + parent = 2 same-org peers.
|
||||
if len(peers) != 2 {
|
||||
t.Fatalf("expected 2 same-org peers (sibling + parent), got %d: %v", len(peers), peers)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, p := range peers {
|
||||
names[fmt.Sprint(p["name"])] = true
|
||||
}
|
||||
if !names["Org A Sibling"] || !names["Org A Root"] {
|
||||
t.Errorf("expected same-org sibling + parent in peer list, got %v", names)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Path 2: MCP toolListPeers — mcp_tools.toolListPeers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// mcpPeerCols matches toolListPeers' SELECT column set.
|
||||
var mcpPeerCols = []string{"id", "name", "role", "status", "tier"}
|
||||
|
||||
// TestToolListPeers_CrossTenant_OrgRootNotLeaked is the #1953 regression for
|
||||
// the MCP path. Same shape as the discovery test: an org-root caller must NOT
|
||||
// enumerate other tenants' org roots. The cross-tenant `parent_id IS NULL`
|
||||
// sibling query is intentionally not registered, so if it runs sqlmock fails.
|
||||
func TestToolListPeers_CrossTenant_OrgRootNotLeaked(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.MatchExpectationsInOrder(false)
|
||||
h := &MCPHandler{database: dbHandleForTest()}
|
||||
|
||||
caller := "org-a-root"
|
||||
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil))
|
||||
|
||||
// LEAKY sibling query (pre-fix). Returns another tenant's org root. The fix
|
||||
// must NOT issue this for an org-root caller; if it does, org-b-root leaks
|
||||
// into the output and the assertion below fails. Left optional via
|
||||
// unordered matching, so the fixed path simply never consumes it.
|
||||
mock.ExpectQuery("WHERE w.parent_id IS NULL AND w.id != \\$1").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows(mcpPeerCols).
|
||||
AddRow("org-b-root", "Org B Root", "lead", "online", 0))
|
||||
|
||||
// Children — caller's own org-A children only.
|
||||
mock.ExpectQuery("WHERE w.parent_id = \\$1 AND w.status").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows(mcpPeerCols).
|
||||
AddRow("org-a-child", "Org A Child", "worker", "online", 1))
|
||||
|
||||
out, err := h.toolListPeers(context.Background(), caller)
|
||||
if err != nil {
|
||||
t.Fatalf("toolListPeers returned error: %v", err)
|
||||
}
|
||||
if strings.Contains(out, "org-b-root") || strings.Contains(out, "Org B Root") {
|
||||
t.Fatalf("cross-tenant leak (#1953): another tenant's org root appeared in toolListPeers output:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "org-a-child") {
|
||||
t.Errorf("same-org child missing from toolListPeers output:\n%s", out)
|
||||
}
|
||||
// ExpectationsWereMet intentionally NOT asserted — leaky sibling expectation
|
||||
// is deliberately left unconsumed by the fixed path.
|
||||
}
|
||||
|
||||
// TestToolListPeers_SameOrg_SiblingsStillWork — positive companion for the MCP
|
||||
// path: a non-root child still enumerates its same-org siblings + children + parent.
|
||||
func TestToolListPeers_SameOrg_SiblingsStillWork(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := &MCPHandler{database: dbHandleForTest()}
|
||||
|
||||
caller := "org-a-child-1"
|
||||
parent := "org-a-root"
|
||||
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(parent))
|
||||
|
||||
// Siblings — scoped to shared parent.
|
||||
mock.ExpectQuery("WHERE w.parent_id = \\$1 AND w.id != \\$2 AND w.status").
|
||||
WithArgs(parent, caller).
|
||||
WillReturnRows(sqlmock.NewRows(mcpPeerCols).
|
||||
AddRow("org-a-child-2", "Org A Sibling", "worker", "online", 1))
|
||||
|
||||
// Children — none.
|
||||
mock.ExpectQuery("WHERE w.parent_id = \\$1 AND w.status").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows(mcpPeerCols))
|
||||
|
||||
// Parent.
|
||||
mock.ExpectQuery("WHERE w.id = \\$1 AND w.status").
|
||||
WithArgs(parent).
|
||||
WillReturnRows(sqlmock.NewRows(mcpPeerCols).
|
||||
AddRow(parent, "Org A Root", "lead", "online", 0))
|
||||
|
||||
out, err := h.toolListPeers(context.Background(), caller)
|
||||
if err != nil {
|
||||
t.Fatalf("toolListPeers returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "Org A Sibling") || !strings.Contains(out, "Org A Root") {
|
||||
t.Errorf("expected same-org sibling + parent in toolListPeers output:\n%s", out)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Path 3: a2a routing — a2a_proxy.proxyA2ARequest / resolveAgentURL
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// TestProxyA2A_CrossTenant_RoutingDenied is the #1953 regression for a2a
|
||||
// routing. Caller and target are both org roots (parent_id IS NULL) belonging
|
||||
// to DIFFERENT tenants. Pre-fix, CanCommunicate's "root-level siblings" rule
|
||||
// waved this through and resolveAgentURL routed to the foreign tenant. Post-fix
|
||||
// the org-scope guard resolves each to a different org root and returns 403
|
||||
// BEFORE resolveAgentURL/dispatch.
|
||||
func TestProxyA2A_CrossTenant_RoutingDenied(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mr := setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
caller := "org-a-root"
|
||||
target := "org-b-root" // different tenant
|
||||
|
||||
// A URL exists for the target; the guard must deny BEFORE it is used.
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", target), "http://localhost:1")
|
||||
|
||||
// CanCommunicate: both root-level (parent_id NULL) → its weak "root-level
|
||||
// siblings" rule ALLOWS this. The org guard must catch it afterward.
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(caller, nil))
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
|
||||
WithArgs(target).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(target, nil))
|
||||
|
||||
// #1953 org-scope guard: caller resolves to org-a-root, target to org-b-root
|
||||
// → different orgs → 403. (Each org root resolves to itself.)
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(caller))
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS").
|
||||
WithArgs(target).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(target))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: target}}
|
||||
body := `{"method":"message/send","params":{"message":{"role":"user","parts":[{"text":"cross-tenant"}]}}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+target+"/a2a", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("X-Workspace-ID", caller)
|
||||
|
||||
handler.ProxyA2A(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for cross-tenant a2a routing, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("body not JSON: %v", err)
|
||||
}
|
||||
if msg, _ := resp["error"].(string); !strings.Contains(msg, "different org") {
|
||||
t.Errorf("expected cross-org denial message, got %v", resp["error"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveAgentURL_CrossTenant_RejectedViaSameOrg is a direct unit test of
|
||||
// the sameOrg primitive that gates resolveAgentURL: a target in a different org
|
||||
// must be reported as NOT same-org, so the a2a guard rejects it before
|
||||
// resolveAgentURL is ever called.
|
||||
func TestResolveAgentURL_CrossTenant_RejectedViaSameOrg(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
|
||||
caller := "org-a-root"
|
||||
target := "org-b-root"
|
||||
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(caller))
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS").
|
||||
WithArgs(target).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(target))
|
||||
|
||||
ok, err := sameOrg(context.Background(), dbHandleForTest(), caller, target)
|
||||
if err != nil {
|
||||
t.Fatalf("sameOrg returned unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Errorf("expected cross-tenant workspaces to be reported as DIFFERENT orgs, got sameOrg=true")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyA2A_SameOrg_RoutingAllowed — positive companion for a2a: two
|
||||
// same-org siblings route successfully (mirrors TestProxyA2A_CallerIDPropagated
|
||||
// but named to document the #1953 same-org allow path).
|
||||
func TestProxyA2A_SameOrg_RoutingAllowed(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mr := setupTestRedis(t)
|
||||
allowLoopbackForTest(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
||||
|
||||
caller := "org-a-child-1"
|
||||
target := "org-a-child-2"
|
||||
parent := "org-a-root"
|
||||
|
||||
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"jsonrpc":"2.0","id":"1","result":{}}`)
|
||||
}))
|
||||
defer agentServer.Close()
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", target), agentServer.URL)
|
||||
|
||||
// CanCommunicate — siblings under shared parent.
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(caller, parent))
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
|
||||
WithArgs(target).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(target, parent))
|
||||
|
||||
// #1953 org guard — both resolve to the same org root → allowed.
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(parent))
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS").
|
||||
WithArgs(target).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(parent))
|
||||
|
||||
expectBudgetCheck(mock, target)
|
||||
mock.ExpectExec("INSERT INTO activity_logs").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: target}}
|
||||
body := `{"method":"message/send","params":{"message":{"role":"user","parts":[{"text":"same-org"}]}}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+target+"/a2a", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("X-Workspace-ID", caller)
|
||||
|
||||
handler.ProxyA2A(c)
|
||||
time.Sleep(50 * time.Millisecond) // allow the async logA2ASuccess INSERT to flush
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for same-org a2a routing, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -140,14 +140,7 @@ func buildHTTPResponse(statusCode int, body string) []byte {
|
||||
}
|
||||
|
||||
// setupIntegrationFixtures inserts the rows executeDelegation requires:
|
||||
// - workspaces: source (org root) + target as its CHILD, so both live in the
|
||||
// SAME org. CanCommunicate=true (parent↔child) AND the #1953 sameOrg() guard
|
||||
// in proxyA2ARequest passes (both resolve to the same org root). A real
|
||||
// delegation happens INSIDE one org. (Previously both were parent_id=NULL —
|
||||
// two DISTINCT org roots — which only "communicated" via CanCommunicate's
|
||||
// root-sibling rule; #1953 added a sameOrg() guard that now denies routing
|
||||
// between two org roots as cross-tenant, so the success-path tests below
|
||||
// must use a same-org source/target pair.)
|
||||
// - workspaces: source and target (siblings, parent_id=NULL so CanCommunicate=true)
|
||||
// - activity_logs: the 'delegate' row that updateDelegationStatus UPDATE will find
|
||||
// - delegations: the ledger row that recordLedgerStatus will UPDATE
|
||||
//
|
||||
@@ -155,14 +148,13 @@ func buildHTTPResponse(statusCode int, body string) []byte {
|
||||
func setupIntegrationFixtures(t *testing.T, conn *sql.DB) func() {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
sourceID := integrationTestSourceID // org root (parent_id NULL); target hangs off it
|
||||
for _, ws := range []struct {
|
||||
id string
|
||||
name string
|
||||
parentID *string
|
||||
}{
|
||||
{integrationTestSourceID, "test-source", nil},
|
||||
{integrationTestTargetID, "test-target", &sourceID}, // child of source → same org
|
||||
{integrationTestTargetID, "test-target", nil},
|
||||
} {
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`INSERT INTO workspaces (id, name, parent_id) VALUES ($1::uuid, $2, $3) ON CONFLICT (id) DO NOTHING`,
|
||||
@@ -518,94 +510,6 @@ func TestIntegration_ExecuteDelegation_RedisDown_FallsBackToDB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_SameOrg_RealCTE_ResolvesAncestorChain is the regression gate
|
||||
// for the org_scope.go recursive-CTE bug (#1953 follow-up). The sqlmock unit
|
||||
// tests feed sameOrg() a pre-computed root_id row, so they CANNOT catch a wrong
|
||||
// CTE — they assume it already returns the right value. Only a real Postgres
|
||||
// run exercises orgRootSubtreeCTE itself.
|
||||
//
|
||||
// The bug: the CTE carried `id AS root_id` from the recursive SEED, so a
|
||||
// non-root workspace resolved to ITSELF instead of its topmost ancestor. That
|
||||
// made sameOrg() return false for two genuinely same-org workspaces and 403 a
|
||||
// legitimate same-org a2a route (over-block). This test seeds a real
|
||||
// root → child → grandchild chain plus a separate org root, and asserts:
|
||||
// - every node in the chain resolves to the SAME org root (root, child, grandchild)
|
||||
// - two workspaces in the same chain are sameOrg (incl. grandchild ↔ root)
|
||||
// - a workspace in a DIFFERENT chain is NOT sameOrg (cross-tenant stays closed)
|
||||
func TestIntegration_SameOrg_RealCTE_ResolvesAncestorChain(t *testing.T) {
|
||||
conn := integrationDB(t)
|
||||
|
||||
const (
|
||||
rootA = "11111111-1111-1111-1111-111111111111"
|
||||
childA = "22222222-2222-2222-2222-222222222222"
|
||||
grandchildA = "33333333-3333-3333-3333-333333333333"
|
||||
rootB = "44444444-4444-4444-4444-444444444444"
|
||||
)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t.Cleanup(func() {
|
||||
c2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel2()
|
||||
// Delete leaf-first to respect the parent_id self-FK.
|
||||
for _, id := range []string{grandchildA, childA, rootA, rootB} {
|
||||
conn.ExecContext(c2, `DELETE FROM workspaces WHERE id = $1`, id)
|
||||
}
|
||||
})
|
||||
|
||||
// Insert parent-before-child to satisfy the self-referential FK.
|
||||
seed := []struct {
|
||||
id, name string
|
||||
parent *string
|
||||
}{
|
||||
{rootA, "org-a-root", nil},
|
||||
{childA, "org-a-child", strPtr(rootA)},
|
||||
{grandchildA, "org-a-grandchild", strPtr(childA)},
|
||||
{rootB, "org-b-root", nil},
|
||||
}
|
||||
for _, s := range seed {
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`INSERT INTO workspaces (id, name, parent_id) VALUES ($1::uuid, $2, $3) ON CONFLICT (id) DO NOTHING`,
|
||||
s.id, s.name, s.parent); err != nil {
|
||||
t.Fatalf("seed %s: %v", s.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every node in chain A must resolve to rootA via the REAL CTE.
|
||||
for _, id := range []string{rootA, childA, grandchildA} {
|
||||
got, err := orgRootID(ctx, conn, id)
|
||||
if err != nil {
|
||||
t.Fatalf("orgRootID(%s): %v", id, err)
|
||||
}
|
||||
if got != rootA {
|
||||
t.Errorf("orgRootID(%s) = %q, want rootA %q (CTE must walk to topmost ancestor)", id, got, rootA)
|
||||
}
|
||||
}
|
||||
|
||||
// Same-org positives — including the grandchild↔root pair that the buggy
|
||||
// CTE got wrong.
|
||||
for _, pair := range [][2]string{{childA, grandchildA}, {rootA, grandchildA}, {rootA, childA}} {
|
||||
ok, err := sameOrg(ctx, conn, pair[0], pair[1])
|
||||
if err != nil {
|
||||
t.Fatalf("sameOrg(%s,%s): %v", pair[0], pair[1], err)
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("sameOrg(%s,%s) = false, want true (same org chain)", pair[0], pair[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-org negative — isolation must stay closed.
|
||||
for _, pair := range [][2]string{{rootA, rootB}, {grandchildA, rootB}, {childA, rootB}} {
|
||||
ok, err := sameOrg(ctx, conn, pair[0], pair[1])
|
||||
if err != nil {
|
||||
t.Fatalf("sameOrg(%s,%s): %v", pair[0], pair[1], err)
|
||||
}
|
||||
if ok {
|
||||
t.Errorf("sameOrg(%s,%s) = true, want false (different orgs — cross-tenant must stay denied)", pair[0], pair[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractHostPort parses "http://127.0.0.1:PORT/" and returns "127.0.0.1:PORT".
|
||||
func extractHostPort(rawURL string) string {
|
||||
// Simple parse: strip "http://" prefix and trailing slash.
|
||||
|
||||
@@ -1059,25 +1059,13 @@ func expectExecuteDelegationBase(mock sqlmock.Sqlmock) {
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// CanCommunicate: getWorkspaceRef(source) + getWorkspaceRef(target).
|
||||
// Source and target are siblings under one shared parent (one tenant) →
|
||||
// CanCommunicate allowed. (#1953: they must NOT both be parent_id=NULL —
|
||||
// two distinct org roots are now treated as DIFFERENT orgs and routing
|
||||
// between them is denied. A real delegation happens inside one org.)
|
||||
// Both are root-level workspaces (parent_id=NULL) → root-level siblings → allowed.
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
|
||||
WithArgs(testDeliverySourceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testDeliverySourceID, "ws-org-root-159"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testDeliverySourceID, nil))
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
|
||||
WithArgs(testDeliveryTargetID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testDeliveryTargetID, "ws-org-root-159"))
|
||||
|
||||
// #1953 cross-tenant guard: same-org check after CanCommunicate. Both
|
||||
// resolve to the same org root → routing allowed.
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS").
|
||||
WithArgs(testDeliverySourceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow("ws-org-root-159"))
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS").
|
||||
WithArgs(testDeliveryTargetID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow("ws-org-root-159"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testDeliveryTargetID, nil))
|
||||
|
||||
// resolveAgentURL: test callers always set the URL in Redis (mr.Set ws:{id}:url),
|
||||
// so resolveAgentURL gets a cache hit and never falls back to DB.
|
||||
|
||||
@@ -237,17 +237,7 @@ func (h *DiscoveryHandler) Peers(c *gin.Context) {
|
||||
|
||||
var peers []map[string]interface{}
|
||||
|
||||
// Siblings — workspaces sharing the caller's parent.
|
||||
//
|
||||
// #1953 cross-tenant isolation: the OLD code's else-branch handled the
|
||||
// org-root caller (parent_id IS NULL) by returning EVERY workspace with
|
||||
// parent_id IS NULL — i.e. every other tenant's org root, since the
|
||||
// workspaces table has no org_id column. That leaked peer identities/URLs
|
||||
// across tenants. An org root has no siblings inside its own org (each
|
||||
// tenant is a distinct org root), so the org-root caller now gets an empty
|
||||
// sibling set; its real peers are its children, returned below. Only the
|
||||
// parent_id-bound branch enumerates siblings, and that is already scoped to
|
||||
// one parent (one tenant).
|
||||
// Siblings
|
||||
if parentID.Valid {
|
||||
siblings, _ := queryPeerMaps(`
|
||||
SELECT w.id, w.name, COALESCE(w.role, ''), w.tier, w.status,
|
||||
@@ -256,6 +246,14 @@ func (h *DiscoveryHandler) Peers(c *gin.Context) {
|
||||
FROM workspaces w WHERE w.parent_id = $1 AND w.id != $2 AND w.status != 'removed'`,
|
||||
parentID.String, workspaceID)
|
||||
peers = append(peers, siblings...)
|
||||
} else {
|
||||
siblings, _ := queryPeerMaps(`
|
||||
SELECT w.id, w.name, COALESCE(w.role, ''), w.tier, w.status,
|
||||
COALESCE(w.agent_card, 'null'::jsonb), COALESCE(w.url, ''),
|
||||
w.parent_id, w.active_tasks
|
||||
FROM workspaces w WHERE w.parent_id IS NULL AND w.id != $1 AND w.status != 'removed'`,
|
||||
workspaceID)
|
||||
peers = append(peers, siblings...)
|
||||
}
|
||||
|
||||
// Children — exclude self defensively. A child row whose parent_id
|
||||
|
||||
@@ -223,10 +223,10 @@ func TestPeers_RootWorkspace_NoPeers(t *testing.T) {
|
||||
|
||||
peerCols := []string{"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks"}
|
||||
|
||||
// #1953: an org-root caller (parent_id IS NULL) now issues NO sibling
|
||||
// query at all. The old `WHERE w.parent_id IS NULL` sibling read returned
|
||||
// EVERY tenant's org root (cross-tenant leak); an org root has no siblings
|
||||
// inside its own org, so the handler skips the sibling read entirely.
|
||||
// Siblings (other root-level workspaces) — none
|
||||
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id IS NULL AND w.id != \\$1").
|
||||
WithArgs("ws-root-alone").
|
||||
WillReturnRows(sqlmock.NewRows(peerCols))
|
||||
|
||||
// Children — none. #383 added explicit `w.id != $2` self-filter.
|
||||
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.id != \\$2").
|
||||
|
||||
@@ -255,16 +255,22 @@ func TestExtended_SecretsListEmpty(t *testing.T) {
|
||||
// ---------- TestSecretsSet (Extended) ----------
|
||||
|
||||
func TestExtended_SecretsSet(t *testing.T) {
|
||||
// internal#691 follow-up: the per-workspace strip gate consults only
|
||||
// the workspace row. The test's intent is the happy path of persisting
|
||||
// a vendor key, so the mock returns an explicit byok override for this
|
||||
// workspace; the bypass-list check is skipped and the write proceeds.
|
||||
// internal#691: the per-workspace strip gate now defaults to platform_managed
|
||||
// on empty MOLECULE_LLM_BILLING_MODE (closed default). This test's intent is
|
||||
// the happy path of persisting a vendor key, so put the org into byok which
|
||||
// matches the pre-#691 implicit behavior of an unset env.
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "byok")
|
||||
mock := setupTestDB(t)
|
||||
handler := NewSecretsHandler(nil)
|
||||
|
||||
// internal#691: secrets.Set now consults ResolveLLMBillingMode before the
|
||||
// strip gate. Mock returns no row → resolver falls through to the org
|
||||
// default (byok, set via t.Setenv above) → bypass-list check is skipped
|
||||
// and the write proceeds. This pattern is the test-side mirror of the
|
||||
// real-prod fall-through behavior for a fresh workspace with no override.
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("22222222-2222-2222-2222-222222222222").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModeBYOK))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}))
|
||||
|
||||
// Expect INSERT (encrypted value is dynamic, use AnyArg)
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
@@ -302,10 +308,7 @@ func TestExtended_SecretsSet(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtended_SecretsSetRejectsHermesCustomProviderInPlatformManagedMode(t *testing.T) {
|
||||
// internal#691 follow-up: per-workspace resolver looks up the workspace
|
||||
// row. Mock no expectations → resolver hits a sqlmock-unexpected-query
|
||||
// error → default-closed to platform_managed → strip-list rejection
|
||||
// fires for the KIMI_API_KEY write.
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "platform_managed")
|
||||
_ = setupTestDB(t)
|
||||
handler := NewSecretsHandler(nil)
|
||||
|
||||
@@ -450,14 +453,6 @@ func TestExtended_DiscoverMissingHeader(t *testing.T) {
|
||||
|
||||
// ---------- TestPeers (Extended) ----------
|
||||
|
||||
// TestExtended_Peers verifies a root-level (org-root) workspace's peer view.
|
||||
//
|
||||
// #1953: previously a root-level caller issued `WHERE w.parent_id IS NULL`
|
||||
// for siblings, which returned EVERY other tenant's org root as a "peer"
|
||||
// (cross-tenant leak, since the workspaces table has no org_id column). After
|
||||
// the fix an org root has no cross-tenant siblings; its only peers are its own
|
||||
// children. This test asserts the child is returned and that NO sibling query
|
||||
// is issued (no `parent_id IS NULL` read).
|
||||
func TestExtended_Peers(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -468,14 +463,17 @@ func TestExtended_Peers(t *testing.T) {
|
||||
WithArgs("ws-peer").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil))
|
||||
|
||||
// NO root-level sibling query is issued for an org-root caller anymore.
|
||||
// Expect root-level siblings query (parent IS NULL, excluding self)
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs("ws-peer").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks"}).
|
||||
AddRow("ws-sibling", "Sibling Agent", "worker", 1, "online", []byte("null"), "http://localhost:9001", nil, 0))
|
||||
|
||||
// Children query (workspaces with parent_id = ws-peer, excluding self).
|
||||
// Query binds (parent_id, self_id) for the self-filter guard added in #383.
|
||||
// Expect children query (workspaces with parent_id = ws-peer, excluding self)
|
||||
// Query now binds (parent_id, self_id) for the self-filter guard added in #383.
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs("ws-peer", "ws-peer").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks"}).
|
||||
AddRow("ws-child", "Child Agent", "worker", 1, "online", []byte("null"), "http://localhost:9001", "ws-peer", 0))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks"}))
|
||||
|
||||
// No parent query since workspace is root-level
|
||||
|
||||
@@ -495,10 +493,10 @@ func TestExtended_Peers(t *testing.T) {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 peer (the child), got %d", len(resp))
|
||||
t.Fatalf("expected 1 peer, got %d", len(resp))
|
||||
}
|
||||
if resp[0]["name"] != "Child Agent" {
|
||||
t.Errorf("expected peer name 'Child Agent', got %v", resp[0]["name"])
|
||||
if resp[0]["name"] != "Sibling Agent" {
|
||||
t.Errorf("expected peer name 'Sibling Agent', got %v", resp[0]["name"])
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
|
||||
@@ -17,34 +17,26 @@ package handlers
|
||||
// stops the strip for EVERY workspace in the org. Turning it to `platform_managed`
|
||||
// blocks every workspace's own OAuth/vendor keys.
|
||||
//
|
||||
// The first attempt at internal#691 introduced a 3-tier resolution:
|
||||
// The resolver replaces the env-var read with a per-workspace lookup:
|
||||
//
|
||||
// workspace ?? org_default (from tenant_config env var) ?? "platform_managed"
|
||||
//
|
||||
// This is the shape that bit agents-team on 2026-05-26: org_default silently
|
||||
// inherited `platform_managed` (the closed bootstrap default) and shadowed
|
||||
// every workspace that had not set an explicit override. The behavior
|
||||
// contradicted the per-workspace intent of the feature — the org tier was
|
||||
// always meant to be a bootstrap floor, not a policy layer.
|
||||
//
|
||||
// CTO direction (2026-05-26 23:54Z): there is no org tier. The workspace is
|
||||
// the unit of decision. The resolver is now:
|
||||
//
|
||||
// workspaces.llm_billing_mode ?? "platform_managed" (closed bootstrap floor)
|
||||
// workspaces.llm_billing_mode (per-workspace override, NULLABLE)
|
||||
// ?? organizations.llm_billing_mode (org default, fetched via tenant_config)
|
||||
// ?? "platform_managed" (closed default — the existing implicit default)
|
||||
//
|
||||
// Default-closed contract — non-negotiable per the RFC Safety axis:
|
||||
//
|
||||
// - workspace row missing (sql.ErrNoRows) → "platform_managed"
|
||||
// - DB error on the lookup → "platform_managed" + propagated error
|
||||
// - workspace override = NULL → "platform_managed"
|
||||
// - workspace override = unknown / garbled string → "platform_managed"
|
||||
// - workspace override = recognized enum value → that value
|
||||
// - workspace row missing (sql.ErrNoRows) → fall through to org default
|
||||
// - DB error on the lookup → "platform_managed" + propagated error
|
||||
// - workspace override = NULL → fall through to org default
|
||||
// - workspace override = unknown string → "platform_managed" (default-closed)
|
||||
// - org default = NULL / empty / unknown string → "platform_managed" (closed default)
|
||||
// - org default = recognized non-pm string + ws null → org default (byok/disabled honored)
|
||||
//
|
||||
// The ONLY way to resolve to "byok" or "disabled" is an explicit, recognized
|
||||
// string in the workspace override. A NULL row, a transient resolver error,
|
||||
// or a garbled enum value MUST NOT silently flip a workspace off of
|
||||
// platform_managed — that would shadow the bootstrap default and is the exact
|
||||
// failure mode the RFC's Safety hot-spot calls out.
|
||||
// string in the workspace override OR the org default. A NULL JOIN, transient
|
||||
// resolver error, or garbled enum value MUST NOT silently flip a workspace
|
||||
// off of platform_managed — that would shadow the org's billing policy and
|
||||
// is the exact failure mode the RFC's Safety hot-spot calls out.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -58,8 +50,8 @@ import (
|
||||
// Constants mirror molecule-controlplane/internal/credits/llm_billing.go.
|
||||
// Kept as string literals (not imports) because workspace-server has no
|
||||
// build-time dependency on the CP module; the values are stable wire
|
||||
// strings used in the workspaces.llm_billing_mode column check constraint
|
||||
// and the CP route bodies.
|
||||
// strings used in the tenant_config response, the workspaces.llm_billing_mode
|
||||
// column check constraint, and the CP route bodies.
|
||||
const (
|
||||
LLMBillingModePlatformManaged = "platform_managed"
|
||||
LLMBillingModeBYOK = "byok"
|
||||
@@ -69,15 +61,11 @@ const (
|
||||
// BillingModeSource describes which layer of the resolution stack supplied
|
||||
// the final mode. Surfaced via the admin route for operator debug
|
||||
// ("why is this workspace being stripped?") per the RFC Observability axis.
|
||||
//
|
||||
// Post-CTO-simplification (2026-05-26) the resolver has only two layers, so
|
||||
// there are only two source values. BillingModeSourceOrgDefault is removed
|
||||
// — the org tier no longer exists. Any non-explicit workspace value
|
||||
// (NULL, row missing, garbled, DB error) resolves via constant_fallback.
|
||||
type BillingModeSource string
|
||||
|
||||
const (
|
||||
BillingModeSourceWorkspaceOverride BillingModeSource = "workspace_override"
|
||||
BillingModeSourceOrgDefault BillingModeSource = "org_default"
|
||||
BillingModeSourceConstantFallback BillingModeSource = "constant_fallback"
|
||||
)
|
||||
|
||||
@@ -85,23 +73,19 @@ const (
|
||||
// and the strip gate logs at INFO. The same struct is the unit-test fixture
|
||||
// shape, so the resolver test asserts both the mode AND the source per case
|
||||
// (catches a bug where the right mode is returned via the wrong layer).
|
||||
//
|
||||
// OrgDefault was removed alongside the org tier — the field would always be
|
||||
// the constant "platform_managed" now, which is exactly the bootstrap floor
|
||||
// already surfaced via BillingModeSourceConstantFallback. Removing it keeps
|
||||
// the wire shape honest: nothing implies the org is a policy input.
|
||||
type BillingModeResolution struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
ResolvedMode string `json:"resolved_mode"`
|
||||
WorkspaceOverride *string `json:"workspace_override"` // nil = no explicit override
|
||||
Source BillingModeSource `json:"source"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
ResolvedMode string `json:"resolved_mode"`
|
||||
WorkspaceOverride *string `json:"workspace_override"` // nil = inherit
|
||||
OrgDefault string `json:"org_default"` // already default-closed by CP
|
||||
Source BillingModeSource `json:"source"`
|
||||
}
|
||||
|
||||
// isKnownBillingMode is the enum-recognizer for the resolver's default-closed
|
||||
// branch. Returning false for an unknown string forces the resolver to fall
|
||||
// through to the constant fallback — NEVER to honor a garbled value as if
|
||||
// it were valid. This is what makes a row with mode='byokk' (typo) resolve
|
||||
// to platform_managed instead of accidentally to byok.
|
||||
// through to the next layer (or the constant fallback) — NEVER to honor a
|
||||
// garbled value as if it were valid. This is what makes a row with mode='byokk'
|
||||
// (typo) resolve to platform_managed instead of accidentally to byok.
|
||||
func isKnownBillingMode(s string) bool {
|
||||
switch s {
|
||||
case LLMBillingModePlatformManaged, LLMBillingModeBYOK, LLMBillingModeDisabled:
|
||||
@@ -111,25 +95,47 @@ func isKnownBillingMode(s string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeOrgDefault applies the same default-closed contract to the
|
||||
// org-level input as the workspace override gets. The org_default arrives
|
||||
// from tenant_config which already COALESCEs NULL → platform_managed at the
|
||||
// CP SQL layer, but we DO NOT trust that contract here — if CP regresses or
|
||||
// the tenant_config env wasn't populated (race on boot), we still default-
|
||||
// close. Same principle: never honor a garbled value.
|
||||
func normalizeOrgDefault(orgMode string) string {
|
||||
if isKnownBillingMode(orgMode) {
|
||||
return orgMode
|
||||
}
|
||||
return LLMBillingModePlatformManaged
|
||||
}
|
||||
|
||||
// ResolveLLMBillingMode is the canonical resolver. Every code path that
|
||||
// previously gated on `os.Getenv("MOLECULE_LLM_BILLING_MODE") == "platform_managed"`
|
||||
// must call this instead and gate on the returned mode.
|
||||
// must call this instead and gate on the returned mode. The architectural
|
||||
// test (resolver_ast_test.go) asserts there is no remaining call site of
|
||||
// the old shape outside the resolver-input wiring.
|
||||
//
|
||||
// Returning an error does NOT prevent the caller from making a decision —
|
||||
// the returned mode is always a valid enum value (default-closed to
|
||||
// platform_managed) so the caller can proceed without a separate fail-closed
|
||||
// branch. The error is informational: log it, surface it to operators, but
|
||||
// the strip-gate decision is already safe.
|
||||
func ResolveLLMBillingMode(ctx context.Context, workspaceID string) (BillingModeResolution, error) {
|
||||
func ResolveLLMBillingMode(ctx context.Context, workspaceID, orgMode string) (BillingModeResolution, error) {
|
||||
res := BillingModeResolution{
|
||||
WorkspaceID: workspaceID,
|
||||
ResolvedMode: LLMBillingModePlatformManaged,
|
||||
Source: BillingModeSourceConstantFallback,
|
||||
WorkspaceID: workspaceID,
|
||||
OrgDefault: normalizeOrgDefault(orgMode),
|
||||
}
|
||||
|
||||
if workspaceID == "" {
|
||||
// No workspace ID = pre-provision context (templating, validation).
|
||||
// Constant fallback is the only safe answer; there is no row to read.
|
||||
// Resolve against the org default only, no DB read.
|
||||
res.ResolvedMode = res.OrgDefault
|
||||
res.Source = BillingModeSourceOrgDefault
|
||||
if !isKnownBillingMode(orgMode) {
|
||||
// Org default was garbled/NULL and we clamped to platform_managed.
|
||||
// Mark the source as constant_fallback so the operator can see
|
||||
// the clamp happened, not that the org "really" said platform_managed.
|
||||
res.Source = BillingModeSourceConstantFallback
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -141,15 +147,22 @@ func ResolveLLMBillingMode(ctx context.Context, workspaceID string) (BillingMode
|
||||
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
// Workspace row missing — concurrent delete, or pre-create call.
|
||||
// Default-closed to platform_managed; surface this via source=
|
||||
// constant_fallback so operators can see the row-missing case is
|
||||
// being handled as a fallback, not a workspace-explicit decision.
|
||||
// Workspace row missing — concurrent delete, or pre-create call. Don't
|
||||
// silently flip; fall through to org default. Source stays org_default
|
||||
// so operators can see the row-missing case is being handled as a
|
||||
// fallback, not a workspace-explicit decision.
|
||||
res.ResolvedMode = res.OrgDefault
|
||||
res.Source = BillingModeSourceOrgDefault
|
||||
if !isKnownBillingMode(orgMode) {
|
||||
res.Source = BillingModeSourceConstantFallback
|
||||
}
|
||||
return res, nil
|
||||
case err != nil:
|
||||
// DB error — default-closed to platform_managed AND propagate the
|
||||
// error so operators get a structured log line. The caller is
|
||||
// expected to log and continue with the safe default.
|
||||
res.ResolvedMode = LLMBillingModePlatformManaged
|
||||
res.Source = BillingModeSourceConstantFallback
|
||||
return res, fmt.Errorf("resolve workspace llm_billing_mode for %s: %w", workspaceID, err)
|
||||
}
|
||||
|
||||
@@ -161,7 +174,7 @@ func ResolveLLMBillingMode(ctx context.Context, workspaceID string) (BillingMode
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Override row present but the value is NULL or garbled. Default-close.
|
||||
// Override row present but the value is NULL or garbled. Fall through.
|
||||
// If the value was non-NULL but garbled (CHECK constraint should prevent
|
||||
// this, but defense in depth — a future migration could relax the check
|
||||
// or another path could write the column directly), surface the raw
|
||||
@@ -170,20 +183,25 @@ func ResolveLLMBillingMode(ctx context.Context, workspaceID string) (BillingMode
|
||||
raw := wsOverride.String
|
||||
res.WorkspaceOverride = &raw
|
||||
}
|
||||
res.ResolvedMode = res.OrgDefault
|
||||
res.Source = BillingModeSourceOrgDefault
|
||||
if !isKnownBillingMode(orgMode) {
|
||||
res.Source = BillingModeSourceConstantFallback
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SetWorkspaceLLMBillingMode writes the override column. Pass mode=="" to
|
||||
// clear (set to NULL = resolve to the constant fallback). Validates the mode
|
||||
// against the enum set so the route handler doesn't have to duplicate
|
||||
// validation; a garbled mode round-trips as an explicit 400 from the caller,
|
||||
// not a CHECK-constraint error from the DB driver.
|
||||
// clear (set to NULL = inherit). Validates the mode against the enum set
|
||||
// so the route handler doesn't have to duplicate validation; a garbled
|
||||
// mode round-trips as an explicit 400 from the caller, not a CHECK-
|
||||
// constraint error from the DB driver.
|
||||
func SetWorkspaceLLMBillingMode(ctx context.Context, workspaceID, mode string) error {
|
||||
if workspaceID == "" {
|
||||
return errors.New("SetWorkspaceLLMBillingMode: workspace id required")
|
||||
}
|
||||
if mode == "" {
|
||||
// NULL = constant fallback. Caller asked to clear the override.
|
||||
// NULL = inherit. Caller asked to clear the override.
|
||||
res, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET llm_billing_mode = NULL WHERE id = $1`,
|
||||
workspaceID,
|
||||
|
||||
@@ -2,7 +2,7 @@ package handlers
|
||||
|
||||
// llm_billing_mode_handler.go — workspace-server admin routes that read /
|
||||
// write the per-workspace billing mode override (internal#691). These are
|
||||
// the per-tenant routes that CP's /cp/admin/workspaces/:id/llm-billing-mode
|
||||
// the per-tenant routes that CP's new /cp/admin/workspaces/:id/llm-billing-mode
|
||||
// proxies to; the canvas hits them via the CP route, not directly.
|
||||
//
|
||||
// Route shape:
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -35,16 +36,18 @@ import (
|
||||
|
||||
// GetWorkspaceLLMBillingMode handles GET /admin/workspaces/:id/llm-billing-mode.
|
||||
//
|
||||
// Reads only the workspace override; there is no org tier (per CTO direction
|
||||
// 2026-05-26: the workspace is the unit of decision). NULL / row-missing /
|
||||
// garbled rows resolve via the constant fallback to platform_managed.
|
||||
// Reads the workspace override + the org-level default (from the same
|
||||
// MOLECULE_LLM_BILLING_MODE env var the provisioner reads at strip-gate time —
|
||||
// keeps the two paths consistent so the GET result matches what the strip
|
||||
// gate would compute) and returns the structured resolution.
|
||||
func GetWorkspaceLLMBillingMode(c *gin.Context) {
|
||||
workspaceID := strings.TrimSpace(c.Param("id"))
|
||||
if !uuidRegex.MatchString(workspaceID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
|
||||
return
|
||||
}
|
||||
res, err := ResolveLLMBillingMode(c.Request.Context(), workspaceID)
|
||||
orgMode := strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_LLM_BILLING_MODE")))
|
||||
res, err := ResolveLLMBillingMode(c.Request.Context(), workspaceID, orgMode)
|
||||
if err != nil {
|
||||
// Resolver returns a safe default-closed mode alongside the error;
|
||||
// surface the error so the operator sees the DB issue, but the
|
||||
@@ -64,10 +67,9 @@ func GetWorkspaceLLMBillingMode(c *gin.Context) {
|
||||
// PutWorkspaceLLMBillingMode handles PUT /admin/workspaces/:id/llm-billing-mode.
|
||||
//
|
||||
// Body shape: {"mode": "byok" | "platform_managed" | "disabled" | null}
|
||||
// where null clears the override (workspace resolves to the constant
|
||||
// fallback). Omitting "mode" entirely is a 400 — callers must be explicit
|
||||
// about whether they want to set or clear, so a typo'd field name can't
|
||||
// silently no-op.
|
||||
// where null clears the override (workspace inherits the org default again).
|
||||
// Omitting "mode" entirely is a 400 — callers must be explicit about whether
|
||||
// they want to set or clear, so a typo'd field name can't silently no-op.
|
||||
//
|
||||
// On success returns the post-write resolution so the canvas can re-render
|
||||
// without a follow-up GET.
|
||||
@@ -136,7 +138,8 @@ func PutWorkspaceLLMBillingMode(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Read back the resolution so the response reflects post-write state.
|
||||
res, resolveErr := ResolveLLMBillingMode(c.Request.Context(), workspaceID)
|
||||
orgMode := strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_LLM_BILLING_MODE")))
|
||||
res, resolveErr := ResolveLLMBillingMode(c.Request.Context(), workspaceID, orgMode)
|
||||
if resolveErr != nil {
|
||||
// Write succeeded but readback failed — still return 200 with the
|
||||
// best-effort resolution; the safe default is set even on error.
|
||||
|
||||
@@ -11,10 +11,6 @@ package handlers
|
||||
// constraint round-trip (matters because the error message must be
|
||||
// actionable to a canvas user)
|
||||
// - 404 propagates when the workspace row is missing on a set/clear
|
||||
//
|
||||
// Post-CTO-simplification (2026-05-26): the org tier no longer participates
|
||||
// in the resolution; tests that exercised the org-default source now assert
|
||||
// the constant-fallback source instead.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -33,9 +29,10 @@ func init() {
|
||||
|
||||
const testWSID = "44444444-4444-4444-4444-444444444444"
|
||||
|
||||
func TestGetWorkspaceLLMBillingMode_HappyPath_NullRowFallsThroughToConstant(t *testing.T) {
|
||||
func TestGetWorkspaceLLMBillingMode_HappyPath_InheritsOrgDefault(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModeBYOK)
|
||||
mock := setupTestDB(t)
|
||||
// Workspace has no override → resolver returns constant fallback = platform_managed.
|
||||
// Workspace has no override → resolver returns org_default = byok.
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(testWSID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil))
|
||||
@@ -54,11 +51,11 @@ func TestGetWorkspaceLLMBillingMode_HappyPath_NullRowFallsThroughToConstant(t *t
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
t.Errorf("resolved mode: got %q want %q", res.ResolvedMode, LLMBillingModePlatformManaged)
|
||||
if res.ResolvedMode != LLMBillingModeBYOK {
|
||||
t.Errorf("resolved mode: got %q want %q", res.ResolvedMode, LLMBillingModeBYOK)
|
||||
}
|
||||
if res.Source != BillingModeSourceConstantFallback {
|
||||
t.Errorf("source: got %q want %q", res.Source, BillingModeSourceConstantFallback)
|
||||
if res.Source != BillingModeSourceOrgDefault {
|
||||
t.Errorf("source: got %q want %q", res.Source, BillingModeSourceOrgDefault)
|
||||
}
|
||||
if res.WorkspaceOverride != nil {
|
||||
t.Errorf("expected nil override, got %v", *res.WorkspaceOverride)
|
||||
@@ -78,6 +75,7 @@ func TestGetWorkspaceLLMBillingMode_BadUUID_400(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPutWorkspaceLLMBillingMode_SetByok(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModePlatformManaged)
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectExec(`UPDATE workspaces SET llm_billing_mode = \$1 WHERE id = \$2`).
|
||||
WithArgs(LLMBillingModeBYOK, testWSID).
|
||||
@@ -114,6 +112,7 @@ func TestPutWorkspaceLLMBillingMode_SetByok(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPutWorkspaceLLMBillingMode_ExplicitNullClearsOverride(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModePlatformManaged)
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectExec(`UPDATE workspaces SET llm_billing_mode = NULL WHERE id = \$1`).
|
||||
WithArgs(testWSID).
|
||||
@@ -143,8 +142,8 @@ func TestPutWorkspaceLLMBillingMode_ExplicitNullClearsOverride(t *testing.T) {
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
t.Errorf("post-clear resolved: got %q want %q", res.ResolvedMode, LLMBillingModePlatformManaged)
|
||||
}
|
||||
if res.Source != BillingModeSourceConstantFallback {
|
||||
t.Errorf("post-clear source: got %q want %q", res.Source, BillingModeSourceConstantFallback)
|
||||
if res.Source != BillingModeSourceOrgDefault {
|
||||
t.Errorf("post-clear source: got %q want %q", res.Source, BillingModeSourceOrgDefault)
|
||||
}
|
||||
if res.WorkspaceOverride != nil {
|
||||
t.Errorf("post-clear override should be nil, got %v", *res.WorkspaceOverride)
|
||||
|
||||
@@ -5,11 +5,6 @@ package handlers
|
||||
// branch in the default-closed contract; if one of them flips behavior
|
||||
// later the test names will tell the reviewer exactly which RFC clause
|
||||
// regressed.
|
||||
//
|
||||
// Post-CTO-simplification (2026-05-26): the org tier was removed. Cases
|
||||
// that previously exercised org-fallback paths now exercise only the
|
||||
// workspace-level path; the org-as-policy-input scenarios are GONE
|
||||
// because the org no longer participates in the resolution.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -27,17 +22,17 @@ func TestResolveLLMBillingMode_TableDriven(t *testing.T) {
|
||||
mode string
|
||||
source BillingModeSource
|
||||
// hasOverride asserts whether the resolver surfaced the override
|
||||
// value in the result (nil pointer = no explicit override / clean
|
||||
// fallback, non-nil = the row was present even if it ultimately
|
||||
// fell through because it was garbled). Lets us distinguish
|
||||
// "row missing, fell through" from "row present but garbled, fell
|
||||
// through" — both resolve to the same mode but the resolver tells
|
||||
// operators which case it was.
|
||||
// value in the result (nil pointer = clean inherit, non-nil = the
|
||||
// row was present even if it ultimately fell through because it
|
||||
// was garbled). Lets us distinguish "row missing, fell through"
|
||||
// from "row present but garbled, fell through" — both resolve to
|
||||
// the same mode but the resolver tells operators which case it was.
|
||||
hasOverride bool
|
||||
}
|
||||
type tc struct {
|
||||
name string
|
||||
workspaceID string
|
||||
orgMode string
|
||||
setupMock func(m sqlmock.Sqlmock)
|
||||
want want
|
||||
wantErr bool
|
||||
@@ -45,8 +40,9 @@ func TestResolveLLMBillingMode_TableDriven(t *testing.T) {
|
||||
|
||||
cases := []tc{
|
||||
{
|
||||
name: "workspace_override_byok",
|
||||
name: "workspace_override_byok_overrides_pm_org",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModePlatformManaged,
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
@@ -55,8 +51,9 @@ func TestResolveLLMBillingMode_TableDriven(t *testing.T) {
|
||||
want: want{mode: LLMBillingModeBYOK, source: BillingModeSourceWorkspaceOverride, hasOverride: true},
|
||||
},
|
||||
{
|
||||
name: "workspace_override_disabled",
|
||||
name: "workspace_override_disabled_overrides_pm_org",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModePlatformManaged,
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
@@ -65,28 +62,31 @@ func TestResolveLLMBillingMode_TableDriven(t *testing.T) {
|
||||
want: want{mode: LLMBillingModeDisabled, source: BillingModeSourceWorkspaceOverride, hasOverride: true},
|
||||
},
|
||||
{
|
||||
name: "workspace_override_explicit_platform_managed",
|
||||
workspaceID: wsID,
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModePlatformManaged))
|
||||
},
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceWorkspaceOverride, hasOverride: true},
|
||||
},
|
||||
{
|
||||
name: "workspace_override_null_falls_through_to_constant",
|
||||
name: "workspace_override_null_inherits_byok_org",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModeBYOK,
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil))
|
||||
},
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceConstantFallback, hasOverride: false},
|
||||
want: want{mode: LLMBillingModeBYOK, source: BillingModeSourceOrgDefault, hasOverride: false},
|
||||
},
|
||||
{
|
||||
name: "workspace_override_garbled_falls_through_DEFAULT_CLOSED",
|
||||
name: "workspace_override_null_inherits_pm_org",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModePlatformManaged,
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(nil))
|
||||
},
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceOrgDefault, hasOverride: false},
|
||||
},
|
||||
{
|
||||
name: "workspace_override_garbled_falls_through_to_pm_org_DEFAULT_CLOSED",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModePlatformManaged,
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
// CHECK constraint would normally prevent this but if a future
|
||||
// migration loosens it (or a direct UPDATE bypasses it on a
|
||||
@@ -97,40 +97,60 @@ func TestResolveLLMBillingMode_TableDriven(t *testing.T) {
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow("byokk"))
|
||||
},
|
||||
// hasOverride=true because the resolver surfaces the garbled
|
||||
// raw value so operators can spot the corrupt row, but the
|
||||
// resolved mode is still the constant fallback.
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceOrgDefault, hasOverride: true},
|
||||
},
|
||||
{
|
||||
name: "workspace_override_garbled_org_garbled_constant_fallback",
|
||||
workspaceID: wsID,
|
||||
orgMode: "garbled-or-empty",
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow("nonsense"))
|
||||
},
|
||||
// Both layers garbled → constant fallback. Source is constant_fallback
|
||||
// so operators can see the org-default-was-also-bad case explicitly.
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceConstantFallback, hasOverride: true},
|
||||
},
|
||||
{
|
||||
name: "workspace_row_missing_falls_through_to_constant",
|
||||
name: "workspace_row_missing_falls_through_to_org_byok",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModeBYOK,
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}))
|
||||
},
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceConstantFallback, hasOverride: false},
|
||||
want: want{mode: LLMBillingModeBYOK, source: BillingModeSourceOrgDefault, hasOverride: false},
|
||||
},
|
||||
{
|
||||
name: "workspace_id_empty_pre_provision_constant_fallback",
|
||||
name: "workspace_id_empty_pre_provision_org_only",
|
||||
workspaceID: "",
|
||||
orgMode: LLMBillingModeBYOK,
|
||||
setupMock: func(m sqlmock.Sqlmock) { /* no DB read expected — empty ws id short-circuits */ },
|
||||
want: want{mode: LLMBillingModeBYOK, source: BillingModeSourceOrgDefault, hasOverride: false},
|
||||
},
|
||||
{
|
||||
name: "workspace_id_empty_org_garbled_constant_fallback",
|
||||
workspaceID: "",
|
||||
orgMode: "",
|
||||
setupMock: func(m sqlmock.Sqlmock) { /* no DB read */ },
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceConstantFallback, hasOverride: false},
|
||||
},
|
||||
{
|
||||
name: "db_error_default_closed_to_pm_with_error",
|
||||
workspaceID: wsID,
|
||||
orgMode: LLMBillingModeBYOK, // org says byok but DB errored — DO NOT honor org
|
||||
setupMock: func(m sqlmock.Sqlmock) {
|
||||
m.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
},
|
||||
// Critical: a DB error means we can't confirm the workspace
|
||||
// doesn't have an override, so we default to the closed mode.
|
||||
// This is the safer of the two failures — silently flipping to
|
||||
// byok on a DB error would leak the OAuth-keeping behavior to
|
||||
// workspaces whose row says NULL.
|
||||
// Critical: even though orgMode=byok, a DB error means we can't
|
||||
// confirm the workspace doesn't have an override, so we default
|
||||
// to the closed mode. This is the safer of the two failures —
|
||||
// silently flipping to org-byok on a DB error would leak the
|
||||
// OAuth-keeping behavior to workspaces whose row says NULL.
|
||||
want: want{mode: LLMBillingModePlatformManaged, source: BillingModeSourceConstantFallback, hasOverride: false},
|
||||
wantErr: true,
|
||||
},
|
||||
@@ -141,7 +161,7 @@ func TestResolveLLMBillingMode_TableDriven(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
c.setupMock(mock)
|
||||
|
||||
res, err := ResolveLLMBillingMode(ctx, c.workspaceID)
|
||||
res, err := ResolveLLMBillingMode(ctx, c.workspaceID, c.orgMode)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Fatalf("err: got %v wantErr=%v", err, c.wantErr)
|
||||
}
|
||||
@@ -171,14 +191,14 @@ func TestResolveLLMBillingMode_ResolvedModeIsAlwaysValid(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const wsID = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
// Throw a pathological row at the resolver: garbled override.
|
||||
// Resolved mode must still be a recognized enum.
|
||||
// Throw a pathological row at the resolver: garbled override + garbled
|
||||
// org default. Resolved mode must still be a recognized enum.
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow("totally-bogus"))
|
||||
|
||||
res, err := ResolveLLMBillingMode(ctx, wsID)
|
||||
res, err := ResolveLLMBillingMode(ctx, wsID, "also-bogus")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
@@ -186,7 +206,7 @@ func TestResolveLLMBillingMode_ResolvedModeIsAlwaysValid(t *testing.T) {
|
||||
t.Errorf("post-condition violated: resolved mode %q is not a known enum value", res.ResolvedMode)
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
t.Errorf("default-closed contract: garbled override must resolve to platform_managed, got %q", res.ResolvedMode)
|
||||
t.Errorf("default-closed contract: garbled-x-garbled must resolve to platform_managed, got %q", res.ResolvedMode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,15 +97,7 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
|
||||
|
||||
const cols = `SELECT w.id, w.name, COALESCE(w.role,''), w.status, w.tier`
|
||||
|
||||
// Siblings — workspaces sharing the caller's parent.
|
||||
//
|
||||
// #1953 cross-tenant isolation: the OLD else-branch returned every
|
||||
// workspace with parent_id IS NULL when the caller was itself an org root,
|
||||
// i.e. every other tenant's org root (the workspaces table has no org_id
|
||||
// column). That leaked peer identities across tenants via MCP list_peers.
|
||||
// An org root has no siblings inside its own org, so the org-root caller
|
||||
// now gets no siblings; its peers are its children, enumerated below. Only
|
||||
// the parent_id-bound branch enumerates siblings, scoped to one tenant.
|
||||
// Siblings
|
||||
if parentID.Valid {
|
||||
rows, err := h.database.QueryContext(ctx,
|
||||
cols+` FROM workspaces w WHERE w.parent_id = $1 AND w.id != $2 AND w.status != 'removed'`,
|
||||
@@ -115,6 +107,15 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
|
||||
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rows, err := h.database.QueryContext(ctx,
|
||||
cols+` FROM workspaces w WHERE w.parent_id IS NULL AND w.id != $1 AND w.status != 'removed'`,
|
||||
workspaceID)
|
||||
if err == nil {
|
||||
if scanErr := scanPeers(rows); scanErr != nil {
|
||||
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Children
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// org_scope.go — cross-tenant isolation helpers (#1953).
|
||||
//
|
||||
// The `workspaces` table has no `org_id` column; an "org" is the subtree of
|
||||
// workspaces reachable through the `parent_id` chain from a single org root
|
||||
// (a row with parent_id IS NULL). Several code paths historically computed an
|
||||
// org-root sibling set as `WHERE parent_id IS NULL`, which matches EVERY
|
||||
// tenant's org root and therefore leaks peer metadata / routing across tenants.
|
||||
//
|
||||
// This file centralises the org-scoping primitive so peer discovery, the MCP
|
||||
// list_peers tool, and a2a routing all derive "the caller's org" the SAME way
|
||||
// the OFFSEC-015 broadcast fix (commit 5a05302c, workspace_broadcast.go) does:
|
||||
// a recursive CTE that walks the parent_id chain up to the org root. Keeping
|
||||
// the CTE in one place means there is a single, testable source of truth for
|
||||
// tenant isolation rather than four hand-copied queries that can drift.
|
||||
//
|
||||
// NOTE: this is the parent_id-chain scoping that the broadcast fix already
|
||||
// ships. It is deliberately NOT an `org_id` column — adding that column is a
|
||||
// separate architecture decision pending CTO sign-off. See #1953.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// errNoOrgRoot is returned by orgRootID when the workspace id has no row (and
|
||||
// therefore no resolvable org root). Callers translate this into a 404/not-found
|
||||
// at their own layer; it is distinct from a transient DB error so a missing
|
||||
// workspace never gets treated as "belongs to every org".
|
||||
var errNoOrgRoot = errors.New("org root not found for workspace")
|
||||
|
||||
// orgRootSubtreeCTE is the recursive CTE — identical in shape to the OFFSEC-015
|
||||
// broadcast fix — that walks UP the parent_id chain from a single workspace to
|
||||
// its org root. The org root is the row on the chain whose parent_id IS NULL.
|
||||
//
|
||||
// $1 = workspace id to resolve
|
||||
//
|
||||
// The recursive member walks UP the parent_id chain: each step joins to the row
|
||||
// whose id is the current row's parent_id. The topmost ancestor is the single
|
||||
// chain row with parent_id IS NULL — and THAT row's own `id` is the org root.
|
||||
//
|
||||
// We select that parentless row's `id` (aliased root_id). We must NOT carry a
|
||||
// fixed `id AS root_id` from the recursive seed: that value is just the input
|
||||
// workspace id, so a non-root caller (e.g. a child delegating to a sibling)
|
||||
// would resolve to ITSELF instead of its org root, and sameOrg() would wrongly
|
||||
// report two genuinely same-org workspaces as different orgs and 403 a
|
||||
// legitimate a2a route. A workspace that already IS an org root has a one-row
|
||||
// chain whose id == itself, so it correctly resolves to itself.
|
||||
const orgRootSubtreeCTE = `
|
||||
WITH RECURSIVE org_chain AS (
|
||||
SELECT id, parent_id
|
||||
FROM workspaces
|
||||
WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_id
|
||||
FROM workspaces w
|
||||
JOIN org_chain c ON w.id = c.parent_id
|
||||
)
|
||||
SELECT id AS root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1
|
||||
`
|
||||
|
||||
// orgRootID resolves the org root of `workspaceID` by walking the parent_id
|
||||
// chain via orgRootSubtreeCTE. Returns errNoOrgRoot when the workspace (or its
|
||||
// chain) yields no org root row, and the underlying error on any DB failure.
|
||||
//
|
||||
// This is the SAME lookup the broadcast handler performs inline; the three
|
||||
// leak paths in #1953 call this instead of re-deriving "the org" from
|
||||
// `parent_id IS NULL` (which spans all tenants).
|
||||
func orgRootID(ctx context.Context, database *sql.DB, workspaceID string) (string, error) {
|
||||
var root string
|
||||
err := database.QueryRowContext(ctx, orgRootSubtreeCTE, workspaceID).Scan(&root)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", errNoOrgRoot
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if root == "" {
|
||||
return "", errNoOrgRoot
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// sameOrg reports whether workspaces `a` and `b` share an org root, i.e. they
|
||||
// belong to the same tenant. Used by a2a routing to reject resolving/dispatching
|
||||
// to a workspace id outside the caller's org. Fail-CLOSED: any lookup error or
|
||||
// missing org root yields (false, err) so a DB hiccup denies cross-tenant
|
||||
// routing rather than allowing it.
|
||||
func sameOrg(ctx context.Context, database *sql.DB, a, b string) (bool, error) {
|
||||
if a == b {
|
||||
return true, nil
|
||||
}
|
||||
rootA, err := orgRootID(ctx, database, a)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rootB, err := orgRootID(ctx, database, b)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rootA == rootB, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -47,32 +48,37 @@ func isPlatformManagedDirectLLMBypassKey(key string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// platformManagedLLMModeForWorkspace is the per-workspace strip-gate check.
|
||||
// The strip-list is enforced ONLY when this specific workspace's resolved
|
||||
// mode is platform_managed — a workspace with a byok override is allowed
|
||||
// to write its own CLAUDE_CODE_OAUTH_TOKEN / vendor key via the canvas
|
||||
// Secrets tab.
|
||||
// platformManagedLLMModeForWorkspace replaces the org-level platformManagedLLMMode
|
||||
// gate with a per-workspace resolved-mode check (internal#691). The strip-list
|
||||
// is enforced ONLY when this specific workspace's resolved mode is
|
||||
// platform_managed — a workspace with a byok override is allowed to write its
|
||||
// own CLAUDE_CODE_OAUTH_TOKEN / vendor key via the canvas Secrets tab.
|
||||
//
|
||||
// Default-closed: if the resolver hits a DB error, falls back to
|
||||
// platform_managed (the safe-default behavior), so a transient DB failure
|
||||
// during a secret write still rejects the bypass-list keys — fail safer not
|
||||
// freer. This matches the resolver's documented contract.
|
||||
//
|
||||
// Post-CTO-simplification (2026-05-26): there is no longer an org-tier
|
||||
// fallback. The resolver consults only the workspace row, defaulting to
|
||||
// platform_managed when the row is NULL/missing/garbled.
|
||||
func platformManagedLLMModeForWorkspace(c *gin.Context, workspaceID string) bool {
|
||||
res, err := ResolveLLMBillingMode(c.Request.Context(), workspaceID)
|
||||
orgMode := strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_LLM_BILLING_MODE")))
|
||||
res, err := ResolveLLMBillingMode(c.Request.Context(), workspaceID, orgMode)
|
||||
if err != nil {
|
||||
log.Printf("secrets: resolve billing mode for workspace=%s failed: %v (defaulting to platform_managed for safety)", workspaceID, err)
|
||||
}
|
||||
return strings.EqualFold(res.ResolvedMode, LLMBillingModePlatformManaged)
|
||||
}
|
||||
|
||||
// rejectPlatformManagedDirectLLMBypassForWorkspace gates per-workspace
|
||||
// vendor-key writes. The strip-list ONLY applies when this specific
|
||||
// workspace resolves to platform_managed; byok/disabled workspaces can
|
||||
// write their own vendor keys.
|
||||
// platformManagedLLMMode is the legacy org-level gate retained for any test
|
||||
// harness still asserting the env-var-only behavior. Production code paths
|
||||
// must call platformManagedLLMModeForWorkspace instead so a workspace-level
|
||||
// byok override actually takes effect on the secrets-write path.
|
||||
func platformManagedLLMMode() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv("MOLECULE_LLM_BILLING_MODE")), "platform_managed")
|
||||
}
|
||||
|
||||
// rejectPlatformManagedDirectLLMBypassForWorkspace is the per-workspace
|
||||
// successor to rejectPlatformManagedDirectLLMBypass (internal#691). The
|
||||
// strip-list ONLY applies when this specific workspace resolves to
|
||||
// platform_managed; byok/disabled workspaces can write their own vendor keys.
|
||||
func rejectPlatformManagedDirectLLMBypassForWorkspace(c *gin.Context, workspaceID, key string) bool {
|
||||
if !platformManagedLLMModeForWorkspace(c, workspaceID) || !isPlatformManagedDirectLLMBypassKey(key) {
|
||||
return false
|
||||
@@ -85,6 +91,22 @@ func rejectPlatformManagedDirectLLMBypassForWorkspace(c *gin.Context, workspaceI
|
||||
return true
|
||||
}
|
||||
|
||||
// rejectPlatformManagedDirectLLMBypass is the legacy org-level shim. Retained
|
||||
// only for backwards compatibility with any external/test caller still on the
|
||||
// old shape; new code MUST use the per-workspace variant above. Production
|
||||
// code paths (the secrets.go handlers + workspace.go create-secret path) all
|
||||
// switched in internal#691.
|
||||
func rejectPlatformManagedDirectLLMBypass(c *gin.Context, key string) bool {
|
||||
if !platformManagedLLMMode() || !isPlatformManagedDirectLLMBypassKey(key) {
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "direct Hermes custom provider secrets are blocked for platform-managed LLM workspaces; use MODEL/LLM_PROVIDER or the platform LLM proxy env instead",
|
||||
"key": key,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
type SecretsHandler struct {
|
||||
restartFunc func(workspaceID string) // Optional: auto-restart after secret change
|
||||
}
|
||||
@@ -223,11 +245,6 @@ func (h *SecretsHandler) Values(c *gin.Context) {
|
||||
// provisioner path in workspace_provision.go so env-vars look identical
|
||||
// whether the workspace was bootstrapped locally or remotely).
|
||||
out := map[string]string{}
|
||||
// Provenance side-channel (internal#711): which keys in `out` originated
|
||||
// from global_secrets and were NOT overridden by a workspace_secrets row.
|
||||
// Used by the provider-aware gate below so a non-platform workspace's
|
||||
// remote pull never receives the platform's scope:global LLM credential.
|
||||
globalKeys := map[string]struct{}{}
|
||||
// Track decrypt failures so we can refuse the response with a list
|
||||
// instead of returning a partial bundle that boots a broken agent.
|
||||
var failedKeys []string
|
||||
@@ -253,7 +270,6 @@ func (h *SecretsHandler) Values(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
out[k] = string(decrypted)
|
||||
globalKeys[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := globalRows.Err(); err != nil {
|
||||
@@ -278,10 +294,6 @@ func (h *SecretsHandler) Values(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
out[k] = string(decrypted) // workspace override wins over global
|
||||
// User explicitly re-set this via the canvas Secrets tab — it is
|
||||
// no longer "the operator-store version", so drop the global
|
||||
// provenance flag (mirrors loadWorkspaceSecrets).
|
||||
delete(globalKeys, k)
|
||||
}
|
||||
}
|
||||
if err := wsRows.Err(); err != nil {
|
||||
@@ -297,32 +309,6 @@ func (h *SecretsHandler) Values(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// internal#711: provider-aware gate on the remote-pull path. A workspace
|
||||
// whose resolved billing mode is NOT platform_managed (byok / subscription)
|
||||
// must NOT receive the platform's scope:global LLM credentials
|
||||
// (CLAUDE_CODE_OAUTH_TOKEN + the rest of the bypass-key set). Those keys
|
||||
// were merged from global_secrets above; here we drop any that are still
|
||||
// of global provenance (a workspace override survives, since its flag was
|
||||
// cleared). Symmetric with applyPlatformManagedLLMEnv's strip on the
|
||||
// provision/restart env path — both injection vectors are now gated.
|
||||
//
|
||||
// Default-closed: ResolveLLMBillingMode collapses any DB error / NULL /
|
||||
// garbled value to platform_managed, so a transient failure leaves the
|
||||
// existing (global-inheriting) behavior in place rather than stripping a
|
||||
// platform_managed workspace's creds.
|
||||
orgMode := strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_LLM_BILLING_MODE")))
|
||||
res, resolveErr := ResolveLLMBillingMode(ctx, workspaceID, orgMode)
|
||||
if resolveErr != nil {
|
||||
log.Printf("secrets.Values: resolve billing mode workspace=%s err=%v (defaulting to platform_managed)", workspaceID, resolveErr)
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
for k := range globalKeys {
|
||||
if isPlatformManagedDirectLLMBypassKey(k) {
|
||||
delete(out, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -490,12 +476,9 @@ func (h *SecretsHandler) SetGlobal(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
// internal#691 follow-up: there is no longer an org-tier billing mode.
|
||||
// Global secret writes are unconditionally allowed; per-workspace
|
||||
// platform_managed strip happens at provision time in
|
||||
// applyPlatformManagedLLMEnv (workspace_provision.go), which will drop
|
||||
// any conflicting global LLM key for workspaces resolving to
|
||||
// platform_managed without affecting byok workspaces.
|
||||
if rejectPlatformManagedDirectLLMBypass(c, body.Key) {
|
||||
return
|
||||
}
|
||||
|
||||
encrypted, err := crypto.Encrypt([]byte(body.Value))
|
||||
if err != nil {
|
||||
|
||||
@@ -865,12 +865,6 @@ func TestSecretsValues_LegacyWorkspaceGrandfathered(t *testing.T) {
|
||||
WithArgs(testWsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}).
|
||||
AddRow("WS_KEY", []byte("ws_plainvalue"), 0))
|
||||
// internal#711: Values now resolves billing mode to gate the global LLM-cred
|
||||
// merge. Neither key here is a platform-managed LLM bypass key, so the mode
|
||||
// is immaterial to the assertions — but the resolver query must be mocked.
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(testWsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModePlatformManaged))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c := secretsValuesRequest(w, "") // no auth — grandfathered
|
||||
@@ -948,12 +942,6 @@ func TestSecretsValues_ValidTokenReturnsDecryptedMerge(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}).
|
||||
AddRow("ONLY_WS", []byte("ws_val"), 0).
|
||||
AddRow("SHARED_KEY", []byte("ws_wins"), 0))
|
||||
// internal#711: billing-mode resolver query. None of these keys is a
|
||||
// platform-managed LLM bypass key, so the resolved mode does not affect the
|
||||
// merge assertions; platform_managed keeps the existing pass-through.
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(testWsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModePlatformManaged))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c := secretsValuesRequest(w, "Bearer good-token")
|
||||
@@ -975,68 +963,6 @@ func TestSecretsValues_ValidTokenReturnsDecryptedMerge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretsValues_ByokStripsGlobalLLMCred is the internal#711 regression
|
||||
// guard for the remote-pull injection vector. A non-platform (byok) workspace
|
||||
// that pulls its secrets via GET /workspaces/:id/secrets/values must NOT
|
||||
// receive the platform's scope:global CLAUDE_CODE_OAUTH_TOKEN — that key is
|
||||
// of global_secrets provenance and is dropped by the provider-aware gate.
|
||||
// Its OWN ANTHROPIC_API_KEY (a workspace_secrets row) survives, and unrelated
|
||||
// non-LLM global secrets are untouched.
|
||||
func TestSecretsValues_ByokStripsGlobalLLMCred(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewSecretsHandler(nil)
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WithArgs(testWsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery(`SELECT t\.id, t\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces`).
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-1", testWsID))
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens SET last_used_at`).
|
||||
WithArgs("tok-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// global_secrets holds the platform's scope:global OAuth token + a
|
||||
// non-LLM operator global (should be untouched).
|
||||
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM global_secrets`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}).
|
||||
AddRow("CLAUDE_CODE_OAUTH_TOKEN", []byte("PLATFORM-GLOBAL-OAUTH"), 0).
|
||||
AddRow("SENTRY_DSN", []byte("https://sentry.example/123"), 0))
|
||||
// The workspace brought its OWN Anthropic API key via the Secrets tab.
|
||||
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id`).
|
||||
WithArgs(testWsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}).
|
||||
AddRow("ANTHROPIC_API_KEY", []byte("CUSTOMER-OWN-ANTHROPIC-KEY"), 0))
|
||||
// Resolver: this workspace is byok.
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(testWsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModeBYOK))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c := secretsValuesRequest(w, "Bearer good-token")
|
||||
handler.Values(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
// 1. Platform global OAuth token stripped — the leak is closed on the pull path.
|
||||
if got, ok := body["CLAUDE_CODE_OAUTH_TOKEN"]; ok {
|
||||
t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN = %q present — platform scope:global token must be stripped for byok pull", got)
|
||||
}
|
||||
// 2. The workspace's own LLM key survives.
|
||||
if body["ANTHROPIC_API_KEY"] != "CUSTOMER-OWN-ANTHROPIC-KEY" {
|
||||
t.Fatalf("ANTHROPIC_API_KEY = %q, want the workspace's own key preserved", body["ANTHROPIC_API_KEY"])
|
||||
}
|
||||
// 3. Unrelated non-LLM global secrets are untouched.
|
||||
if body["SENTRY_DSN"] != "https://sentry.example/123" {
|
||||
t.Fatalf("SENTRY_DSN = %q, want non-LLM globals untouched", body["SENTRY_DSN"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretsValues_InvalidWorkspaceID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
handler := NewSecretsHandler(nil)
|
||||
|
||||
@@ -75,21 +75,3 @@ func formatMissingEnvError(missing []string) string {
|
||||
strings.Join(missing, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
// formatMissingBYOKCredentialError builds the user-facing message for a
|
||||
// provision failure caused by a non-platform (byok/subscription) workspace
|
||||
// that has no usable LLM credential of its own (internal#711). The platform's
|
||||
// scope:global LLM credentials are NOT a valid fallback for a non-platform
|
||||
// workspace — resolving to them would bill the platform's Anthropic credits —
|
||||
// so the provision fails closed here rather than starting the workspace on
|
||||
// stripped/absent creds. Rendered verbatim in the canvas Events tab.
|
||||
func formatMissingBYOKCredentialError(mode string) string {
|
||||
return fmt.Sprintf(
|
||||
"this workspace's LLM billing mode is %q (not platform-managed) but it has no LLM credential of its own. "+
|
||||
"Add a workspace-scoped credential (e.g. CLAUDE_CODE_OAUTH_TOKEN or your provider's API key) under "+
|
||||
"Config → Secrets, or switch the workspace to platform-managed billing via "+
|
||||
"/admin/workspaces/:id/llm-billing-mode, then retry. The platform's shared LLM credentials are not "+
|
||||
"used for non-platform workspaces.",
|
||||
mode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -943,47 +943,7 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
||||
// MOLECULE_LLM_BILLING_MODE_RESOLVED so an in-container debug check can
|
||||
// answer "what mode is this workspace running under" without DB queries
|
||||
// (RFC Observability hot-spot).
|
||||
//
|
||||
// internal#711 — PROVIDER-AWARE GLOBAL-LLM-CRED GATE. The platform's
|
||||
// LLM credentials (CLAUDE_CODE_OAUTH_TOKEN + the rest of the
|
||||
// platformManagedDirectLLMBypassKeys set) live in `global_secrets` and
|
||||
// are merged into EVERY workspace's env by loadWorkspaceSecrets — that
|
||||
// merge is provenance-blind. Pre-fix, the non-platform (byok/disabled)
|
||||
// early-return left envVars untouched, so a BYOK / subscription
|
||||
// workspace that brought NO LLM credential of its own still inherited
|
||||
// the platform's scope:global CLAUDE_CODE_OAUTH_TOKEN and ran Opus on
|
||||
// the platform's (Molecule's) Anthropic credits (Reno Stars SEO +
|
||||
// Marketing agents, confirmed live 2026-05-27).
|
||||
//
|
||||
// The gate: on the non-platform path we strip every platform-managed
|
||||
// LLM key whose PROVENANCE is `global_secrets` (the globalKeys set).
|
||||
// A workspace's OWN LLM credential — set via the canvas Secrets tab,
|
||||
// i.e. a `workspace_secrets` row — has had its global provenance flag
|
||||
// dropped by loadWorkspaceSecrets, so it is NOT in globalKeys and
|
||||
// survives. Net effect: platform global LLM creds reach a workspace
|
||||
// ONLY when its resolved mode is platform_managed; a non-platform
|
||||
// workspace resolves to its own (workspace-scoped) credential or none.
|
||||
//
|
||||
// The boolean return reports whether, after the gate, the workspace
|
||||
// still has at least one usable LLM credential. The caller
|
||||
// (prepareProvisionContext) uses it to FAIL CLOSED — a non-platform
|
||||
// workspace with no usable LLM credential is aborted with a clear
|
||||
// MISSING_BYOK_CREDENTIAL error at provision time rather than being
|
||||
// started on (now-stripped) platform creds.
|
||||
// platformLLMEnvResult is the structured outcome of applyPlatformManagedLLMEnv.
|
||||
// ResolvedMode is the per-workspace billing/provider mode the resolver
|
||||
// landed on. HasUsableLLMCred reports whether — AFTER the provider-aware
|
||||
// global-cred gate — the workspace still has at least one platform-managed
|
||||
// LLM credential key in its env (its own, workspace-scoped one). Only the
|
||||
// non-platform path consults HasUsableLLMCred for the fail-closed decision;
|
||||
// the platform_managed path always returns true (it forces the CP proxy
|
||||
// usage token, which IS the usable credential).
|
||||
type platformLLMEnvResult struct {
|
||||
ResolvedMode string
|
||||
HasUsableLLMCred bool
|
||||
}
|
||||
|
||||
func applyPlatformManagedLLMEnv(ctx context.Context, envVars map[string]string, globalKeys map[string]struct{}, workspaceID, runtime, model string) platformLLMEnvResult {
|
||||
func applyPlatformManagedLLMEnv(ctx context.Context, envVars map[string]string, workspaceID, runtime, model string) {
|
||||
orgMode := strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_LLM_BILLING_MODE")))
|
||||
res, resolveErr := ResolveLLMBillingMode(ctx, workspaceID, orgMode)
|
||||
if resolveErr != nil {
|
||||
@@ -1006,35 +966,18 @@ func applyPlatformManagedLLMEnv(ctx context.Context, envVars map[string]string,
|
||||
// pulling logs or hitting the admin route.
|
||||
envVars["MOLECULE_LLM_BILLING_MODE_RESOLVED"] = res.ResolvedMode
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
// byok or disabled — DO NOT force-route to CP, DO NOT override the
|
||||
// workspace's own ANTHROPIC_BASE_URL / OAuth token.
|
||||
//
|
||||
// internal#711: but DO strip platform-origin LLM credentials. The
|
||||
// platform's scope:global CLAUDE_CODE_OAUTH_TOKEN (+ the rest of the
|
||||
// bypass-key set) was merged into envVars by loadWorkspaceSecrets
|
||||
// from global_secrets; without this strip a BYOK workspace that
|
||||
// brought no LLM credential of its own would inherit the platform's
|
||||
// global token and bill the platform's Anthropic credits. The strip
|
||||
// is PROVENANCE-AWARE: only keys still flagged as global_secrets
|
||||
// origin are removed; a workspace's own LLM cred (a workspace_secrets
|
||||
// row — provenance flag already dropped by loadWorkspaceSecrets)
|
||||
// survives so the workspace talks to its own provider directly.
|
||||
stripGlobalOriginLLMCreds(envVars, globalKeys)
|
||||
return platformLLMEnvResult{
|
||||
ResolvedMode: res.ResolvedMode,
|
||||
HasUsableLLMCred: hasAnyPlatformManagedLLMKey(envVars),
|
||||
}
|
||||
// byok or disabled — DO NOT strip vendor keys, DO NOT force-route to CP,
|
||||
// DO NOT override the workspace own ANTHROPIC_BASE_URL / OAuth token.
|
||||
// Leave envVars alone so CLAUDE_CODE_OAUTH_TOKEN / vendor API keys
|
||||
// pulled from workspace_secrets survive into the container, and the
|
||||
// workspace talks to its own provider directly (internal#703).
|
||||
return
|
||||
}
|
||||
baseURL := firstNonEmptyEnv("MOLECULE_LLM_BASE_URL", "OPENAI_BASE_URL")
|
||||
anthropicBaseURL := firstNonEmptyEnv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "ANTHROPIC_BASE_URL")
|
||||
token := firstNonEmptyEnv("MOLECULE_LLM_USAGE_TOKEN", "OPENAI_API_KEY")
|
||||
if baseURL == "" || token == "" {
|
||||
// Proxy not configured (boot race / misconfig). On the platform_managed
|
||||
// path the workspace IS entitled to platform creds, so we do NOT strip
|
||||
// here — but we report HasUsableLLMCred from whatever survived so the
|
||||
// caller's fail-closed branch (non-platform only) is never reached on
|
||||
// this path.
|
||||
return platformLLMEnvResult{ResolvedMode: res.ResolvedMode, HasUsableLLMCred: true}
|
||||
return
|
||||
}
|
||||
stripPlatformManagedLLMBypassEnv(envVars)
|
||||
|
||||
@@ -1063,10 +1006,6 @@ func applyPlatformManagedLLMEnv(ctx context.Context, envVars map[string]string,
|
||||
envVars["MOLECULE_MODEL"] = defaultModel
|
||||
}
|
||||
}
|
||||
// platform_managed: the CP proxy usage token (injected as ANTHROPIC_API_KEY
|
||||
// / OPENAI_API_KEY above) IS the usable credential, so the workspace is
|
||||
// never fail-closed on this path.
|
||||
return platformLLMEnvResult{ResolvedMode: res.ResolvedMode, HasUsableLLMCred: true}
|
||||
}
|
||||
|
||||
func stripPlatformManagedLLMBypassEnv(envVars map[string]string) {
|
||||
@@ -1075,41 +1014,6 @@ func stripPlatformManagedLLMBypassEnv(envVars map[string]string) {
|
||||
}
|
||||
}
|
||||
|
||||
// stripGlobalOriginLLMCreds removes platform-managed LLM credential keys
|
||||
// (CLAUDE_CODE_OAUTH_TOKEN + the rest of platformManagedDirectLLMBypassKeys)
|
||||
// from envVars ONLY when they originated from the operator-controlled
|
||||
// `global_secrets` table (i.e. their key is present in globalKeys).
|
||||
//
|
||||
// internal#711 provider-aware gate. A platform global LLM credential is the
|
||||
// platform's own credential and must never be the credential a non-platform
|
||||
// (byok / subscription) workspace runs on. loadWorkspaceSecrets drops the
|
||||
// global-provenance flag for any key the workspace re-set via the canvas
|
||||
// Secrets tab (a workspace_secrets row), so a workspace's OWN LLM credential
|
||||
// is NOT in globalKeys and survives this strip — only the inherited platform
|
||||
// global creds are removed.
|
||||
func stripGlobalOriginLLMCreds(envVars map[string]string, globalKeys map[string]struct{}) {
|
||||
for key := range platformManagedDirectLLMBypassKeys {
|
||||
if _, fromGlobal := globalKeys[key]; fromGlobal {
|
||||
delete(envVars, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyPlatformManagedLLMKey reports whether envVars still carries at least
|
||||
// one non-empty platform-managed LLM credential key after the provider-aware
|
||||
// gate. Used by the non-platform fail-closed branch: a byok/subscription
|
||||
// workspace with no surviving (workspace-scoped) LLM credential must be
|
||||
// aborted with MISSING_BYOK_CREDENTIAL rather than started credential-less or
|
||||
// on stripped platform creds.
|
||||
func hasAnyPlatformManagedLLMKey(envVars map[string]string) bool {
|
||||
for key := range platformManagedDirectLLMBypassKeys {
|
||||
if strings.TrimSpace(envVars[key]) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func runtimeUsesAnthropicNativeProxy(runtime string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(runtime), "claude-code")
|
||||
}
|
||||
|
||||
@@ -193,35 +193,7 @@ func (h *WorkspaceHandler) prepareProvisionContext(
|
||||
// continue to rely on workspace_secrets / org-import persona-env
|
||||
// merge for their git auth.
|
||||
applyAgentGitHTTPCreds(envVars, payload.Role)
|
||||
// internal#711: provider-aware LLM-credential resolution. On a non-platform
|
||||
// (byok/subscription) workspace this strips the platform's scope:global LLM
|
||||
// creds inherited from global_secrets and reports whether the workspace
|
||||
// still has a usable (workspace-scoped) LLM credential of its own.
|
||||
llmRes := applyPlatformManagedLLMEnv(ctx, envVars, globalSecretKeys, workspaceID, payload.Runtime, payload.Model)
|
||||
// Fail closed for a BYOK workspace with no usable LLM credential: do NOT
|
||||
// start it on the platform's (now-stripped) global creds. Mirror the
|
||||
// "model+provider+credential REQUIRED at create" spirit (internal#711)
|
||||
// with an actionable error surfaced at provision time.
|
||||
//
|
||||
// Scoped to byok specifically (NOT disabled): "byok" means "the user
|
||||
// intends to run an LLM on their own credential" — a missing one is a
|
||||
// misconfiguration worth surfacing loudly. "disabled" means "this
|
||||
// workspace runs no platform-billed LLM at all" (terminal / file work, or
|
||||
// a runtime that talks to a non-bypass-key endpoint); stripping the
|
||||
// inherited platform globals is sufficient there and aborting would
|
||||
// regress a legitimate no-LLM workspace. The strip above already ran for
|
||||
// both non-platform modes.
|
||||
//
|
||||
// The bypass-key check is intentionally broad — any surviving bypass key
|
||||
// (the workspace's own, of workspace_secrets provenance) clears it.
|
||||
if llmRes.ResolvedMode == LLMBillingModeBYOK && !llmRes.HasUsableLLMCred {
|
||||
msg := formatMissingBYOKCredentialError(llmRes.ResolvedMode)
|
||||
log.Printf("Provisioner: ABORT workspace=%s — byok billing mode has no usable LLM credential (MISSING_BYOK_CREDENTIAL, internal#711)", workspaceID)
|
||||
return nil, &provisionAbort{
|
||||
Msg: msg,
|
||||
Extra: map[string]interface{}{"error": msg, "code": "MISSING_BYOK_CREDENTIAL", "billing_mode": llmRes.ResolvedMode, "issue": "711"},
|
||||
}
|
||||
}
|
||||
applyPlatformManagedLLMEnv(ctx, envVars, workspaceID, payload.Runtime, payload.Model)
|
||||
applyRuntimeModelEnv(envVars, payload.Runtime, payload.Model)
|
||||
if payload.Role != "" {
|
||||
envVars["MOLECULE_AGENT_ROLE"] = payload.Role
|
||||
|
||||
@@ -494,57 +494,6 @@ func TestPrepareProvisionContext_WorkspaceSecretWinsOverPersonaToken(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareProvisionContext_ByokWithOnlyGlobalOAuthFailsClosed is the
|
||||
// internal#711 end-to-end guard for the live Reno Stars leak. A byok
|
||||
// workspace whose ONLY LLM credential is the platform's scope:global
|
||||
// CLAUDE_CODE_OAUTH_TOKEN (inherited from global_secrets, no workspace
|
||||
// override) must:
|
||||
//
|
||||
// 1. have that platform token STRIPPED from the prepared env (no leak), and
|
||||
// 2. ABORT the provision with the MISSING_BYOK_CREDENTIAL code rather than
|
||||
// start the workspace on the platform's credits.
|
||||
//
|
||||
// This is the discriminating end-to-end test: pre-fix prepared.EnvVars would
|
||||
// carry CLAUDE_CODE_OAUTH_TOKEN=<platform token> and the provision would
|
||||
// succeed, running Opus on Molecule's Anthropic credits.
|
||||
func TestPrepareProvisionContext_ByokWithOnlyGlobalOAuthFailsClosed(t *testing.T) {
|
||||
const wsID = "352e3c2b-0546-4e9c-b487-1e2ff1cf29fc" // Reno Stars SEO agent
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModePlatformManaged)
|
||||
|
||||
mock := setupTestDB(t)
|
||||
// global_secrets carries the platform's scope:global OAuth token.
|
||||
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM global_secrets`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}).
|
||||
AddRow("CLAUDE_CODE_OAUTH_TOKEN", []byte("PLATFORM-GLOBAL-OAUTH"), 0))
|
||||
// Workspace set NO secrets of its own.
|
||||
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM workspace_secrets`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}))
|
||||
// Resolver: workspace override = byok.
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModeBYOK))
|
||||
|
||||
handler := NewWorkspaceHandler(&captureBroadcaster{}, nil, "http://localhost:8080", t.TempDir())
|
||||
payload := models.CreateWorkspacePayload{
|
||||
Name: "Reno Stars SEO",
|
||||
Runtime: "claude-code",
|
||||
Tier: 1,
|
||||
}
|
||||
prepared, abort := handler.prepareProvisionContext(
|
||||
context.Background(), wsID, "/nonexistent", nil, payload, false)
|
||||
|
||||
if abort == nil {
|
||||
t.Fatalf("expected MISSING_BYOK_CREDENTIAL abort, got success (prepared=%v) — the leak would still ship", prepared)
|
||||
}
|
||||
if code, _ := abort.Extra["code"].(string); code != "MISSING_BYOK_CREDENTIAL" {
|
||||
t.Fatalf("abort.Extra[code] = %v, want MISSING_BYOK_CREDENTIAL", abort.Extra["code"])
|
||||
}
|
||||
if mode, _ := abort.Extra["billing_mode"].(string); mode != LLMBillingModeBYOK {
|
||||
t.Fatalf("abort.Extra[billing_mode] = %v, want %q", abort.Extra["billing_mode"], LLMBillingModeBYOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadOrLazyHealInboundSecret pins the four branches of the
|
||||
// shared lazy-heal helper directly. Each call site (chat_files,
|
||||
// registry) has its own integration test, but those go through the
|
||||
@@ -1023,7 +972,7 @@ func TestApplyPlatformManagedLLMEnv_NonClaudeRuntimeDefaultsOpenAIProxyWhenNoWor
|
||||
t.Setenv("MOLECULE_LLM_DEFAULT_MODEL", "moonshot/kimi-k2.6")
|
||||
|
||||
envVars := map[string]string{}
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, nil, "", "codex", "")
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, "", "codex", "")
|
||||
applyRuntimeModelEnv(envVars, "codex", "")
|
||||
|
||||
if got := envVars["OPENAI_BASE_URL"]; got != "https://api.example.test/api/v1/internal/llm/openai/v1" {
|
||||
@@ -1053,7 +1002,7 @@ func TestApplyPlatformManagedLLMEnv_StripsWorkspaceOpenAIKeyForClaudeCode(t *tes
|
||||
"OPENAI_BASE_URL": "https://api.openai.com/v1",
|
||||
"MODEL": "openai/gpt-5.5",
|
||||
}
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, nil, "", "claude-code", "")
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, "", "claude-code", "")
|
||||
|
||||
if _, ok := envVars["OPENAI_API_KEY"]; ok {
|
||||
t.Fatalf("OPENAI_API_KEY should be stripped for claude-code platform-managed mode")
|
||||
@@ -1079,7 +1028,7 @@ func TestApplyPlatformManagedLLMEnv_ClaudeCodeUsesAnthropicProxyOverOAuth(t *tes
|
||||
"CLAUDE_CODE_OAUTH_TOKEN": "user-oauth-token",
|
||||
"MODEL": "sonnet",
|
||||
}
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, nil, "", "claude-code", "")
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, "", "claude-code", "")
|
||||
|
||||
if _, ok := envVars["CLAUDE_CODE_OAUTH_TOKEN"]; ok {
|
||||
t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN should be stripped in platform-managed mode")
|
||||
@@ -1102,7 +1051,7 @@ func TestApplyPlatformManagedLLMEnv_ClaudeCodeInjectsAnthropicProxyWhenNoWorkspa
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
|
||||
envVars := map[string]string{}
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, nil, "", "claude-code", "minimax/MiniMax-M2.7")
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, "", "claude-code", "minimax/MiniMax-M2.7")
|
||||
|
||||
if got := envVars["ANTHROPIC_BASE_URL"]; got != "https://api.example.test/api/v1/internal/llm/anthropic/v1" {
|
||||
t.Fatalf("ANTHROPIC_BASE_URL = %q", got)
|
||||
@@ -1125,7 +1074,7 @@ func TestApplyPlatformManagedLLMEnv_ClaudeCodeStripsVendorBYOK(t *testing.T) {
|
||||
"MINIMAX_API_KEY": "user-minimax-key",
|
||||
"MODEL": "MiniMax-M2.7",
|
||||
}
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, nil, "", "claude-code", "")
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, "", "claude-code", "")
|
||||
|
||||
if _, ok := envVars["MINIMAX_API_KEY"]; ok {
|
||||
t.Fatalf("MINIMAX_API_KEY should be stripped in platform-managed mode")
|
||||
@@ -1147,7 +1096,7 @@ func TestApplyPlatformManagedLLMEnv_NoopsOutsidePlatformManaged(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
|
||||
envVars := map[string]string{}
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, nil, "", "claude-code", "")
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, "", "claude-code", "")
|
||||
|
||||
if _, ok := envVars["OPENAI_API_KEY"]; ok {
|
||||
t.Fatalf("OPENAI_API_KEY should not be set outside platform-managed mode")
|
||||
@@ -1188,7 +1137,7 @@ func TestApplyPlatformManagedLLMEnv_ClaudeCodeByokKeepsOwnProviderEnv(t *testing
|
||||
"CLAUDE_CODE_OAUTH_TOKEN": "user-oauth-token",
|
||||
"MODEL": "sonnet",
|
||||
}
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, nil, wsID, "claude-code", "")
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, wsID, "claude-code", "")
|
||||
|
||||
// 1. OAuth token intact — not stripped.
|
||||
if got := envVars["CLAUDE_CODE_OAUTH_TOKEN"]; got != "user-oauth-token" {
|
||||
@@ -1219,182 +1168,6 @@ func TestApplyPlatformManagedLLMEnv_ClaudeCodeByokKeepsOwnProviderEnv(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyPlatformManagedLLMEnv_ByokStripsGlobalOriginOAuthToken is the
|
||||
// internal#711 regression guard for the live 2026-05-27 leak (Reno Stars SEO
|
||||
// + Marketing claude-code agents). A non-platform (byok) workspace that
|
||||
// brought NO LLM credential of its own, but which inherited the platform's
|
||||
// scope:global CLAUDE_CODE_OAUTH_TOKEN from global_secrets (provenance =
|
||||
// globalKeys), must have that platform token STRIPPED — not run on it.
|
||||
//
|
||||
// Pre-fix the byok early-return left envVars untouched, so the platform's
|
||||
// global OAuth token survived into the container and the agent ran Opus on
|
||||
// the platform's Anthropic credits. The fix gates the global-cred merge on
|
||||
// provider==platform: a non-platform workspace keeps only its own
|
||||
// (workspace_secrets) creds, of which there are none here.
|
||||
func TestApplyPlatformManagedLLMEnv_ByokStripsGlobalOriginOAuthToken(t *testing.T) {
|
||||
const wsID = "352e3c2b-0546-4e9c-b487-1e2ff1cf29fc" // Reno Stars SEO agent
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModeBYOK))
|
||||
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModePlatformManaged)
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
|
||||
t.Setenv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "https://api.example.test/api/v1/internal/llm/anthropic")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
|
||||
// The ONLY LLM credential in env is the platform's scope:global OAuth
|
||||
// token, merged from global_secrets (so its key is in globalKeys). The
|
||||
// workspace set none of its own.
|
||||
envVars := map[string]string{
|
||||
"CLAUDE_CODE_OAUTH_TOKEN": "PLATFORM-GLOBAL-OAUTH-TOKEN",
|
||||
"MODEL": "opus",
|
||||
}
|
||||
globalKeys := map[string]struct{}{"CLAUDE_CODE_OAUTH_TOKEN": {}}
|
||||
|
||||
res := applyPlatformManagedLLMEnv(context.Background(), envVars, globalKeys, wsID, "claude-code", "")
|
||||
|
||||
// 1. The platform global OAuth token must be STRIPPED — the leak is closed.
|
||||
if got, ok := envVars["CLAUDE_CODE_OAUTH_TOKEN"]; ok {
|
||||
t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN = %q present — platform scope:global token must be stripped for a byok workspace", got)
|
||||
}
|
||||
// 2. No CP proxy creds forced (byok = workspace talks to its own provider).
|
||||
if got, ok := envVars["ANTHROPIC_API_KEY"]; ok {
|
||||
t.Fatalf("ANTHROPIC_API_KEY must NOT be injected for byok, got %q", got)
|
||||
}
|
||||
// 3. Resolver reports byok with NO usable LLM credential → caller fails closed.
|
||||
if res.ResolvedMode != LLMBillingModeBYOK {
|
||||
t.Fatalf("ResolvedMode = %q, want %q", res.ResolvedMode, LLMBillingModeBYOK)
|
||||
}
|
||||
if res.HasUsableLLMCred {
|
||||
t.Fatalf("HasUsableLLMCred = true, want false (only the stripped platform global token was present)")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyPlatformManagedLLMEnv_ByokKeepsWorkspaceOwnOAuthEvenWithGlobal is
|
||||
// the discriminating companion to the strip test: a byok workspace that DID
|
||||
// set its own CLAUDE_CODE_OAUTH_TOKEN via the canvas Secrets tab (a
|
||||
// workspace_secrets row) keeps it. loadWorkspaceSecrets drops the global
|
||||
// provenance flag on a workspace override, so the key is NOT in globalKeys
|
||||
// and the provenance-aware strip leaves it alone. Proves the fix strips only
|
||||
// platform-origin creds, never the customer's own.
|
||||
func TestApplyPlatformManagedLLMEnv_ByokKeepsWorkspaceOwnOAuthEvenWithGlobal(t *testing.T) {
|
||||
const wsID = "6b66de8d-9337-4fb4-be8d-6d49dca0d809" // Reno Stars Marketing agent
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModeBYOK))
|
||||
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModePlatformManaged)
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
|
||||
// Workspace set its OWN OAuth token — loadWorkspaceSecrets would have
|
||||
// dropped its global provenance flag, so globalKeys does NOT contain it.
|
||||
envVars := map[string]string{
|
||||
"CLAUDE_CODE_OAUTH_TOKEN": "CUSTOMER-OWN-OAUTH-TOKEN",
|
||||
"MODEL": "opus",
|
||||
}
|
||||
globalKeys := map[string]struct{}{} // not from global_secrets
|
||||
|
||||
res := applyPlatformManagedLLMEnv(context.Background(), envVars, globalKeys, wsID, "claude-code", "")
|
||||
|
||||
if got := envVars["CLAUDE_CODE_OAUTH_TOKEN"]; got != "CUSTOMER-OWN-OAUTH-TOKEN" {
|
||||
t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN = %q, want the workspace's own token left intact", got)
|
||||
}
|
||||
if !res.HasUsableLLMCred {
|
||||
t.Fatalf("HasUsableLLMCred = false, want true (workspace brought its own credential)")
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModeBYOK {
|
||||
t.Fatalf("ResolvedMode = %q, want %q", res.ResolvedMode, LLMBillingModeBYOK)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyPlatformManagedLLMEnv_DisabledStripsGlobalButReportsNoCred proves
|
||||
// that "disabled" mode also strips the platform's global LLM creds (the leak
|
||||
// is closed for disabled too), and reports HasUsableLLMCred=false. The
|
||||
// caller's fail-closed abort is scoped to byok only, so a disabled workspace
|
||||
// with no LLM cred still boots (for terminal / non-LLM work); here we pin the
|
||||
// function-level strip + report.
|
||||
func TestApplyPlatformManagedLLMEnv_DisabledStripsGlobalButReportsNoCred(t *testing.T) {
|
||||
const wsID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModeDisabled))
|
||||
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModePlatformManaged)
|
||||
|
||||
envVars := map[string]string{
|
||||
"CLAUDE_CODE_OAUTH_TOKEN": "PLATFORM-GLOBAL-OAUTH-TOKEN",
|
||||
}
|
||||
globalKeys := map[string]struct{}{"CLAUDE_CODE_OAUTH_TOKEN": {}}
|
||||
|
||||
res := applyPlatformManagedLLMEnv(context.Background(), envVars, globalKeys, wsID, "claude-code", "")
|
||||
|
||||
if _, ok := envVars["CLAUDE_CODE_OAUTH_TOKEN"]; ok {
|
||||
t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN must be stripped for disabled mode too")
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModeDisabled {
|
||||
t.Fatalf("ResolvedMode = %q, want %q", res.ResolvedMode, LLMBillingModeDisabled)
|
||||
}
|
||||
if res.HasUsableLLMCred {
|
||||
t.Fatalf("HasUsableLLMCred = true, want false")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyPlatformManagedLLMEnv_PlatformManagedStillReceivesGlobalCreds is
|
||||
// the no-regression guard for the OTHER side of the gate (internal#711): a
|
||||
// platform-managed workspace MUST still receive the platform's creds. Here
|
||||
// the proxy IS configured, so the contract is the existing one — the global
|
||||
// OAuth token is replaced by the proxy usage token (HasUsableLLMCred=true).
|
||||
func TestApplyPlatformManagedLLMEnv_PlatformManagedStillReceivesGlobalCreds(t *testing.T) {
|
||||
const wsID = "99999999-9999-9999-9999-999999999999"
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModePlatformManaged))
|
||||
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModePlatformManaged)
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://api.example.test/api/v1/internal/llm/openai/v1")
|
||||
t.Setenv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "https://api.example.test/api/v1/internal/llm/anthropic")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tenant-admin-token")
|
||||
|
||||
envVars := map[string]string{
|
||||
"CLAUDE_CODE_OAUTH_TOKEN": "PLATFORM-GLOBAL-OAUTH-TOKEN",
|
||||
"MODEL": "opus",
|
||||
}
|
||||
globalKeys := map[string]struct{}{"CLAUDE_CODE_OAUTH_TOKEN": {}}
|
||||
|
||||
res := applyPlatformManagedLLMEnv(context.Background(), envVars, globalKeys, wsID, "claude-code", "")
|
||||
|
||||
// Platform-managed routes through the CP proxy: OAuth stripped, proxy creds forced.
|
||||
if _, ok := envVars["CLAUDE_CODE_OAUTH_TOKEN"]; ok {
|
||||
t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN should be stripped + replaced by the proxy token for platform_managed")
|
||||
}
|
||||
if got := envVars["ANTHROPIC_API_KEY"]; got != "tenant-admin-token" {
|
||||
t.Fatalf("ANTHROPIC_API_KEY = %q, want proxy usage token for platform_managed", got)
|
||||
}
|
||||
if !res.HasUsableLLMCred {
|
||||
t.Fatalf("HasUsableLLMCred = false, want true for platform_managed (proxy token is the credential)")
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
t.Fatalf("ResolvedMode = %q, want %q", res.ResolvedMode, LLMBillingModePlatformManaged)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyPlatformManagedLLMEnv_PlatformManagedStillEmitsResolvedMode is the
|
||||
// no-regression companion: a workspace that resolves to platform_managed must
|
||||
// still strip + force the proxy AND emit MOLECULE_LLM_BILLING_MODE=
|
||||
@@ -1416,7 +1189,7 @@ func TestApplyPlatformManagedLLMEnv_PlatformManagedStillEmitsResolvedMode(t *tes
|
||||
"CLAUDE_CODE_OAUTH_TOKEN": "user-oauth-token",
|
||||
"MODEL": "sonnet",
|
||||
}
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, nil, wsID, "claude-code", "")
|
||||
applyPlatformManagedLLMEnv(context.Background(), envVars, wsID, "claude-code", "")
|
||||
|
||||
// OAuth stripped, proxy forced — unchanged platform_managed contract.
|
||||
if _, ok := envVars["CLAUDE_CODE_OAUTH_TOKEN"]; ok {
|
||||
|
||||
@@ -501,11 +501,10 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
|
||||
// while persisting a secret causes the entire transaction to roll back and
|
||||
// the handler to return 500. The workspace row must NOT be committed.
|
||||
func TestWorkspaceCreate_SecretPersistFails_RollsBack(t *testing.T) {
|
||||
// internal#691 follow-up: see TestExtended_SecretsSet — the per-workspace
|
||||
// resolver consults only the workspace row. This test is asserting the
|
||||
// rollback path on DB failure, not the strip gate, so the workspace
|
||||
// row mock below returns an explicit byok override and the OPENAI_API_KEY
|
||||
// write reaches the INSERT-and-fail path.
|
||||
// internal#691: see TestExtended_SecretsSet — same default-closed reasoning.
|
||||
// This test is asserting the rollback path on DB failure, not the strip gate;
|
||||
// keep the org in byok so the OPENAI_API_KEY write reaches the INSERT.
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", "byok")
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
@@ -517,11 +516,11 @@ func TestWorkspaceCreate_SecretPersistFails_RollsBack(t *testing.T) {
|
||||
// internal#691: Create() now resolves billing mode per-workspace before
|
||||
// the secret-strip gate. The workspace row was just inserted in the same
|
||||
// transaction so it isn't readable from a separate query yet; the
|
||||
// resolver expects the SELECT and the mock returns an explicit byok
|
||||
// override so the OPENAI_API_KEY write reaches the INSERT-and-fail path
|
||||
// this test exercises.
|
||||
// resolver expects the SELECT and the mock returns no row → falls back
|
||||
// to the org default (byok, set above) so the OPENAI_API_KEY write
|
||||
// reaches the INSERT-and-fail path this test exercises.
|
||||
mock.ExpectQuery(`SELECT llm_billing_mode FROM workspaces WHERE id = \$1`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}).AddRow(LLMBillingModeBYOK))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"llm_billing_mode"}))
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
WillReturnError(sql.ErrConnDone) // DB failure while writing secret
|
||||
mock.ExpectRollback() // workspace insert must be rolled back
|
||||
|
||||
Reference in New Issue
Block a user