forked from molecule-ai/molecule-core
fix(security): add AdminAuth to /admin/workspaces/:id/test-token route
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 <noreply@anthropic.com>
This commit is contained in:
parent
15f55f2fb0
commit
3e1e68004d
101
platform/internal/router/admin_test_token_route_test.go
Normal file
101
platform/internal/router/admin_test_token_route_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user