From 3e1e68004d35706e8f2b0defcb346dc9ba217d8a Mon Sep 17 00:00:00 2001 From: Molecule AI Backend Engineer Date: Fri, 17 Apr 2026 02:48:00 +0000 Subject: [PATCH] fix(security): add AdminAuth to /admin/workspaces/:id/test-token route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without middleware, any caller on a non-production instance could mint a bearer token for any workspace UUID with no authentication. AdminAuth is defence-in-depth: on a fresh install (no tokens yet) it is fail-open so the bootstrap path still works; once the first workspace enrolls a token all callers must present a valid bearer. Adds two router-level tests confirming the gate: - TestTestTokenRoute_RequiresAdminAuth_WhenTokensExist → 401 with no header - TestTestTokenRoute_FailOpenOnFreshInstall → 200 (bootstrap path intact) Env-var gating inside GetTestToken is retained as a second layer. Co-Authored-By: Claude Sonnet 4.6 --- .../router/admin_test_token_route_test.go | 101 ++++++++++++++++++ platform/internal/router/router.go | 8 +- 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 platform/internal/router/admin_test_token_route_test.go diff --git a/platform/internal/router/admin_test_token_route_test.go b/platform/internal/router/admin_test_token_route_test.go new file mode 100644 index 00000000..bf288b35 --- /dev/null +++ b/platform/internal/router/admin_test_token_route_test.go @@ -0,0 +1,101 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware" + "github.com/gin-gonic/gin" +) + +// buildTestTokenEngine builds a minimal Gin engine containing only the +// test-token route with AdminAuth middleware — the same registration that +// router.go now uses. Allows us to verify the auth gate is enforced at the +// HTTP layer without spinning up the full Setup() dependency graph. +func buildTestTokenEngine(t *testing.T) gin.IRouter { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + tokh := handlers.NewAdminTestTokenHandler() + r.GET("/admin/workspaces/:id/test-token", middleware.AdminAuth(db.DB), tokh.GetTestToken) + return r +} + +// setupRouterTestDB initialises db.DB with a sqlmock connection and returns +// the mock controller. Restores db.DB on test cleanup. +func setupRouterTestDB(t *testing.T) sqlmock.Sqlmock { + t.Helper() + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + prev := db.DB + db.DB = mockDB + t.Cleanup(func() { + db.DB = prev + mockDB.Close() + }) + return mock +} + +// TestTestTokenRoute_RequiresAdminAuth_WhenTokensExist verifies that once the +// platform has at least one live token, the test-token endpoint returns 401 +// for callers that provide no Authorization header. This is the core security +// property added by the fix — without AdminAuth in the router the request +// would reach the handler and mint a new bearer for any workspace UUID. +func TestTestTokenRoute_RequiresAdminAuth_WhenTokensExist(t *testing.T) { + t.Setenv("MOLECULE_ENV", "development") // enable the handler itself + mock := setupRouterTestDB(t) + + // HasAnyLiveTokenGlobal: platform has one enrolled workspace. + mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := buildTestTokenEngine(t) + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/admin/workspaces/ws-target/test-token", nil) + // No Authorization header — should be rejected by AdminAuth. + r.(http.Handler).ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 when tokens exist and no auth header, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) + } +} + +// TestTestTokenRoute_FailOpenOnFreshInstall verifies that AdminAuth is +// fail-open on a fresh install (HasAnyLiveTokenGlobal == 0), so the test-token +// bootstrap path still works before the first workspace has registered. +func TestTestTokenRoute_FailOpenOnFreshInstall(t *testing.T) { + t.Setenv("MOLECULE_ENV", "development") + mock := setupRouterTestDB(t) + + // HasAnyLiveTokenGlobal: no tokens yet — fresh install. + mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + // Handler's own DB queries: workspace existence check + token insert. + mock.ExpectQuery("SELECT id FROM workspaces WHERE id ="). + WithArgs("ws-bootstrap"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-bootstrap")) + mock.ExpectExec("INSERT INTO workspace_auth_tokens"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + r := buildTestTokenEngine(t) + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/admin/workspaces/ws-bootstrap/test-token", nil) + r.(http.Handler).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 on fresh install (fail-open), got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock expectations not met: %v", err) + } +} diff --git a/platform/internal/router/router.go b/platform/internal/router/router.go index 5a76f640..88c04bd0 100644 --- a/platform/internal/router/router.go +++ b/platform/internal/router/router.go @@ -297,11 +297,13 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi } // Admin — test token minting (issue #6). Hidden in production via TestTokensEnabled(). - // Registered at root (not inside AdminAuth) because it is itself the bootstrap for - // acquiring a token, and it's gated on MOLECULE_ENV / MOLECULE_ENABLE_TEST_TOKENS. + // AdminAuth is a second defence-in-depth layer: on a fresh install with no tokens yet, + // AdminAuth is fail-open (HasAnyLiveTokenGlobal == 0), so the bootstrap still works. + // Once any token exists, callers must present a valid bearer — unauthenticated workspace- + // UUID enumeration is blocked even on non-production instances. { tokh := handlers.NewAdminTestTokenHandler() - r.GET("/admin/workspaces/:id/test-token", tokh.GetTestToken) + r.GET("/admin/workspaces/:id/test-token", middleware.AdminAuth(db.DB), tokh.GetTestToken) } // Terminal — shares Docker client with provisioner