Merge pull request #162 from Molecule-AI/fix/issue-138-field-whitelist

fix(auth): #138 — field-level authz on PATCH /workspaces/:id (canvas regression fix)
This commit is contained in:
Hongming Wang 2026-04-15 09:39:22 -07:00 committed by GitHub
commit 175bc2de50
3 changed files with 140 additions and 8 deletions

View File

@ -390,6 +390,20 @@ func (h *WorkspaceHandler) State(c *gin.Context) {
})
}
// sensitiveUpdateFields gates the #120/#138 field-level auth check inside
// Update. Any key in this set requires a valid bearer token even when the
// rest of the route is open — tier is a resource-escalation vector,
// parent_id rewrites the A2A hierarchy, runtime swaps the container image
// on next restart, workspace_dir redirects host bind-mounts. Cosmetic
// fields (name, role, x, y, canvas) do not appear here and pass through
// unauthenticated so canvas drag-reposition and inline rename keep working.
var sensitiveUpdateFields = map[string]struct{}{
"tier": {},
"parent_id": {},
"runtime": {},
"workspace_dir": {},
}
// Update handles PATCH /workspaces/:id
func (h *WorkspaceHandler) Update(c *gin.Context) {
id := c.Param("id")
@ -402,6 +416,35 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
ctx := c.Request.Context()
// #138 field-level authz: PATCH /workspaces/:id is on the open router so
// canvas drag-reposition (cookie-based, no bearer token) keeps working,
// BUT the sensitive fields below require a valid bearer via the usual
// admin-token check. Lazy-bootstrap: if no live admin tokens exist at all
// (fresh install) the check is a no-op and everyone passes through.
for field := range body {
if _, sensitive := sensitiveUpdateFields[field]; !sensitive {
continue
}
hasLive, hlErr := wsauth.HasAnyLiveTokenGlobal(ctx, db.DB)
if hlErr != nil {
log.Printf("wsauth: Update HasAnyLiveTokenGlobal failed: %v — allowing request", hlErr)
break
}
if !hasLive {
break // fresh install — fail-open
}
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
if tok == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "admin auth required for field: " + field})
return
}
if err := wsauth.ValidateAnyToken(ctx, db.DB, tok); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin auth token"})
return
}
break // one successful validation covers the whole body
}
// #120: guard — return 404 for nonexistent workspace IDs instead of
// silently applying zero-row UPDATEs and returning 200.
var exists bool

View File

@ -656,3 +656,88 @@ func TestWorkspaceState_ValidTokenReturnsStatus(t *testing.T) {
t.Errorf("status should be 'degraded', got %v", body["status"])
}
}
// ── #138 field-level auth tests ─────────────────────────────────────────────
// Cosmetic PATCH (name/x/y/role) stays open so canvas drag-reposition works
// without a bearer token. Sensitive fields (tier/parent_id/runtime/
// workspace_dir) require a valid admin bearer once any live token exists.
func TestWorkspaceUpdate_CosmeticField_NoBearer_FailOpen_NoTokens(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Body contains only cosmetic field → no wsauth probe ever fires.
mock.ExpectQuery("SELECT EXISTS.*workspaces WHERE id").
WithArgs("ws-cosmetic").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET name").
WithArgs("ws-cosmetic", "Cosmetic").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-cosmetic"}}
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-cosmetic",
bytes.NewBufferString(`{"name":"Cosmetic"}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusOK {
t.Errorf("cosmetic PATCH (no bearer) should pass; got %d: %s", w.Code, w.Body.String())
}
}
func TestWorkspaceUpdate_SensitiveField_NoBearer_TokensExist_Rejected(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// HasAnyLiveTokenGlobal returns 1 — tokens exist on the platform.
mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-sensitive"}}
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-sensitive",
bytes.NewBufferString(`{"tier":4}`))
c.Request.Header.Set("Content-Type", "application/json")
// No Authorization header — must fail closed.
handler.Update(c)
if w.Code != http.StatusUnauthorized {
t.Errorf("sensitive PATCH without bearer: got %d, want 401 (%s)", w.Code, w.Body.String())
}
}
func TestWorkspaceUpdate_SensitiveField_NoTokensYet_FailOpen(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// HasAnyLiveTokenGlobal returns 0 — fresh install, fail-open.
mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectQuery("SELECT EXISTS.*workspaces WHERE id").
WithArgs("ws-bootstrap").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET tier").
WithArgs("ws-bootstrap", float64(4)).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-bootstrap"}}
c.Request = httptest.NewRequest("PATCH", "/workspaces/ws-bootstrap",
bytes.NewBufferString(`{"tier":4}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusOK {
t.Errorf("bootstrap fail-open: got %d, want 200 (%s)", w.Code, w.Body.String())
}
}

View File

@ -99,22 +99,26 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
// without a token (used by WorkspaceNode polling and health checks).
r.GET("/workspaces/:id", wh.Get)
// C1 + C20 + C18-adjacent + #120: workspace list and ALL mutating operations
// gated behind AdminAuth — any valid workspace bearer token grants access.
// PATCH /workspaces/:id — back on the open router per #138. Canvas
// drag-reposition uses session cookies not bearer tokens; gating the
// whole route behind AdminAuth broke drag-to-reposition and inline
// rename. Field-level authz lives inside WorkspaceHandler.Update:
// - {x, y, canvas} only → passthrough (canvas position persist)
// - name / role → passthrough (inline rename)
// - tier / parent_id / runtime / workspace_dir → require bearer token
// The #120 escalation vectors stay locked; only cosmetic fields are open.
r.PATCH("/workspaces/:id", wh.Update)
// C1 + C20: workspace list and life-cycle mutations gated behind AdminAuth.
// Fail-open when no tokens exist anywhere (fresh install / pre-Phase-30).
// This blocks:
// Blocks:
// C1 — unauthenticated GET /workspaces (workspace topology exposure)
// C20 — unauthenticated DELETE /workspaces/:id (mass-deletion attack)
// unauthenticated POST /workspaces (workspace creation)
// #120 — unauthenticated PATCH /workspaces/:id (tier escalation, parent_id
// hierarchy manipulation, runtime swap, workspace_dir path hijack)
// NOTE: canvas position-persist (PATCH with {x,y}) uses the same AdminAuth
// token already required for GET /workspaces list on initial load.
{
wsAdmin := r.Group("", middleware.AdminAuth(db.DB))
wsAdmin.GET("/workspaces", wh.List)
wsAdmin.POST("/workspaces", wh.Create)
wsAdmin.PATCH("/workspaces/:id", wh.Update)
wsAdmin.DELETE("/workspaces/:id", wh.Delete)
}