From 1a28ec8ee58ed97e4c42f04dc33b4f82ac74ea90 Mon Sep 17 00:00:00 2001 From: Backend Engineer Date: Wed, 15 Apr 2026 04:37:14 +0000 Subject: [PATCH] =?UTF-8?q?fix(security):=20C1=20=E2=80=94=20gate=20GET=20?= =?UTF-8?q?/workspaces=20behind=20AdminAuth;=20add=20auth=20middleware=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security Auditor confirmed C1 (GET /workspaces) exposes workspace topology without any authentication. The endpoint was intentionally left open for the canvas browser frontend; this PR closes that gap. Router change: - Move GET /workspaces from the bare root router into the wsAdmin AdminAuth group alongside POST /workspaces and DELETE /workspaces/:id. - AdminAuth uses the same fail-open bootstrap contract as all other auth gates: fresh installs (no live tokens) pass through; once any workspace has registered with a token, a valid bearer is required. Status of findings C2–C11 (documented here for audit trail): - C2 POST /workspaces/:id/activity → already in wsAuth group (Cycle 5) - C3 POST /workspaces/:id/delegations/record → already in wsAuth group (Cycle 5) - C4 POST /workspaces/:id/delegations/:id/update → already in wsAuth group (Cycle 5) - C5 GET /workspaces/:id/delegations → already in wsAuth group (Cycle 5) - C7 GET /workspaces/:id/memories → already in wsAuth group (Cycle 5) - C8 POST /workspaces/:id/memories → already in wsAuth group (Cycle 5) - C9 POST /workspaces/:id/delegate → already in wsAuth group (Cycle 5) - C10 GET /admin/secrets → already in adminAuth group (Cycle 7) - C11 POST+DELETE /admin/secrets → already in adminAuth group (Cycle 7) Tests (platform/internal/middleware/wsauth_middleware_test.go — 13 new): WorkspaceAuth: - fail-open when workspace has no tokens (bootstrap path) - C4: no bearer on /delegations/:id/update → 401 - C8: no bearer on /memories POST → 401 - invalid bearer → 401 - cross-workspace token replay → 401 - valid bearer for correct workspace → 200 AdminAuth: - fail-open when no tokens exist globally (fresh install) - C10: no bearer on GET /admin/secrets → 401 - C11: no bearer on POST /admin/secrets → 401 - C11: no bearer on DELETE /admin/secrets/:key → 401 - valid bearer → 200 - invalid bearer → 401 Note: did NOT touch DELETE /admin/secrets in production — no destructive calls to live secrets endpoints were made during this work. Co-Authored-By: Claude Sonnet 4.6 --- .../middleware/wsauth_middleware_test.go | 474 ++++++++++++++++++ platform/internal/router/router.go | 20 +- 2 files changed, 485 insertions(+), 9 deletions(-) create mode 100644 platform/internal/middleware/wsauth_middleware_test.go diff --git a/platform/internal/middleware/wsauth_middleware_test.go b/platform/internal/middleware/wsauth_middleware_test.go new file mode 100644 index 00000000..50ba5b91 --- /dev/null +++ b/platform/internal/middleware/wsauth_middleware_test.go @@ -0,0 +1,474 @@ +package middleware + +import ( + "crypto/sha256" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" +) + +// ──────────────────────────────────────────────────────────────────────────── +// WorkspaceAuth middleware tests (covers findings C4, C8 and the full +// per-workspace bearer-token contract). +// +// WorkspaceAuth calls wsauth.HasAnyLiveToken to decide whether to enforce: +// - 0 live tokens → fail-open (bootstrap / rolling upgrade) +// - ≥1 live token → Authorization: Bearer required and validated +// ──────────────────────────────────────────────────────────────────────────── + +// hasLiveTokenQuery is the SQL fragment matched by sqlmock for HasAnyLiveToken. +const hasLiveTokenQuery = "SELECT COUNT.*FROM workspace_auth_tokens.*workspace_id" + +// hasAnyLiveTokenGlobalQuery is matched for HasAnyLiveTokenGlobal. +const hasAnyLiveTokenGlobalQuery = "SELECT COUNT.*FROM workspace_auth_tokens" + +// validateTokenQuery is matched for ValidateToken (SELECT). +const validateTokenSelectQuery = "SELECT id, workspace_id.*FROM workspace_auth_tokens.*token_hash" + +// validateAnyTokenQuery is matched for ValidateAnyToken (SELECT). +const validateAnyTokenSelectQuery = "SELECT id.*FROM workspace_auth_tokens.*token_hash" + +// validateTokenUpdateQuery is matched for the best-effort last_used_at UPDATE. +const validateTokenUpdateQuery = "UPDATE workspace_auth_tokens SET last_used_at" + +// newWorkspaceAuthRouter builds a minimal gin router that applies WorkspaceAuth +// to a single GET /workspaces/:id/test route, returning 200 on success. +func newWorkspaceAuthRouter(db sqlmock.Sqlmock, realDB interface{ Close() error }) *gin.Engine { + _ = db // unused directly; sqlmock intercepts calls via the *sql.DB pointer + r := gin.New() + // We need the *sql.DB, not the mock. The caller passes mockDB via the + // test-local var — this helper is only used to build the router topology. + return r +} + +// TestWorkspaceAuth_FailOpen_NoTokens — C4/C8: when a workspace has no live +// token on file (first boot / pre-Phase-30), the middleware must let the +// request through so in-flight agents are not bricked during rolling upgrades. +func TestWorkspaceAuth_FailOpen_NoTokens(t *testing.T) { + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + // HasAnyLiveToken returns 0 — no tokens yet. + mock.ExpectQuery(hasLiveTokenQuery). + WithArgs("ws-bootstrap"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + r := gin.New() + r.GET("/workspaces/:id/test", WorkspaceAuth(mockDB), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/workspaces/ws-bootstrap/test", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("fail-open (no tokens): expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestWorkspaceAuth_C4_C8_NoBearer_Returns401 — C4/C8 critical path: +// when a workspace has live tokens and the caller sends NO bearer token, +// the middleware must return 401. This was the confirmed attack vector — +// unauthenticated POSTs to /delegations/:id/update and /memories succeeded. +func TestWorkspaceAuth_C4_C8_NoBearer_Returns401(t *testing.T) { + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + // HasAnyLiveToken returns 1 — workspace is token-enrolled. + mock.ExpectQuery(hasLiveTokenQuery). + WithArgs("ws-enrolled"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := gin.New() + r.POST("/workspaces/:id/delegations/:delegation_id/update", + WorkspaceAuth(mockDB), + func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + + w := httptest.NewRecorder() + // C4 attack: no Authorization header. + req, _ := http.NewRequest(http.MethodPost, + "/workspaces/ws-enrolled/delegations/del-1/update", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("C4 no-bearer: expected 401, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestWorkspaceAuth_C8_MemoriesCommit_NoBearer_Returns401 tests specifically +// the C8 vector: POST /workspaces/:id/memories without auth. +func TestWorkspaceAuth_C8_MemoriesCommit_NoBearer_Returns401(t *testing.T) { + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + mock.ExpectQuery(hasLiveTokenQuery). + WithArgs("ws-memory-target"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := gin.New() + r.POST("/workspaces/:id/memories", + WorkspaceAuth(mockDB), + func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, + "/workspaces/ws-memory-target/memories", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("C8 no-bearer: expected 401, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestWorkspaceAuth_InvalidBearer_Returns401 — wrong token must be rejected. +func TestWorkspaceAuth_InvalidBearer_Returns401(t *testing.T) { + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + // HasAnyLiveToken: tokens exist. + mock.ExpectQuery(hasLiveTokenQuery). + WithArgs("ws-enrolled"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + // ValidateToken: hash doesn't match any live row. + wrongToken := "wrong-token-value" + wrongHash := sha256.Sum256([]byte(wrongToken)) + mock.ExpectQuery(validateTokenSelectQuery). + WithArgs(wrongHash[:]). + WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"})) // empty → ErrNoRows + + r := gin.New() + r.GET("/workspaces/:id/test", + WorkspaceAuth(mockDB), + func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/workspaces/ws-enrolled/test", nil) + req.Header.Set("Authorization", "Bearer "+wrongToken) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("invalid bearer: expected 401, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestWorkspaceAuth_ValidBearer_Passes — correct token for the right workspace +// must be accepted and the handler reached (200). +func TestWorkspaceAuth_ValidBearer_Passes(t *testing.T) { + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + testToken := "valid-workspace-bearer-token-abc123" + tokenHash := sha256.Sum256([]byte(testToken)) + + // HasAnyLiveToken: workspace has tokens. + mock.ExpectQuery(hasLiveTokenQuery). + WithArgs("ws-enrolled"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + // ValidateToken SELECT — returns matching token_id + workspace_id. + mock.ExpectQuery(validateTokenSelectQuery). + WithArgs(tokenHash[:]). + WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}). + AddRow("tok-1", "ws-enrolled")) + + // Best-effort last_used_at UPDATE (ignored on error, but we expect it). + mock.ExpectExec(validateTokenUpdateQuery). + WithArgs("tok-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + r := gin.New() + r.POST("/workspaces/:id/memories", + WorkspaceAuth(mockDB), + func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/workspaces/ws-enrolled/memories", nil) + req.Header.Set("Authorization", "Bearer "+testToken) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("valid bearer: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestWorkspaceAuth_WrongWorkspace_Returns401 — token valid for workspace A must +// not authenticate workspace B (cross-workspace token replay attack). +func TestWorkspaceAuth_WrongWorkspace_Returns401(t *testing.T) { + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + tokenForA := "token-issued-to-workspace-a" + tokenHash := sha256.Sum256([]byte(tokenForA)) + + // URL targets workspace-b but the token was issued to workspace-a. + mock.ExpectQuery(hasLiveTokenQuery). + WithArgs("workspace-b"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + // ValidateToken SELECT returns workspace-a from DB. + mock.ExpectQuery(validateTokenSelectQuery). + WithArgs(tokenHash[:]). + WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}). + AddRow("tok-a", "workspace-a")) // workspace mismatch! + + r := gin.New() + r.GET("/workspaces/:id/test", + WorkspaceAuth(mockDB), + func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/workspaces/workspace-b/test", nil) + req.Header.Set("Authorization", "Bearer "+tokenForA) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("cross-workspace replay: expected 401, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// AdminAuth middleware tests (covers findings C10, C11 and the +// global bearer-token contract for /admin/secrets, /settings/secrets). +// ──────────────────────────────────────────────────────────────────────────── + +// TestAdminAuth_FailOpen_NoTokensGlobally — C10/C11: on a fresh install (no +// live tokens anywhere) the middleware must let the request through so existing +// deployments keep working during the Phase-30 rollout. +func TestAdminAuth_FailOpen_NoTokensGlobally(t *testing.T) { + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + // HasAnyLiveTokenGlobal returns 0 — fresh install. + mock.ExpectQuery(hasAnyLiveTokenGlobalQuery). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + r := gin.New() + r.GET("/admin/secrets", AdminAuth(mockDB), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/admin/secrets", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("C10 fail-open (no global tokens): expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestAdminAuth_C10_NoBearer_Returns401 — C10 critical path: when at least +// one workspace has tokens, GET /admin/secrets without a bearer → 401. +func TestAdminAuth_C10_NoBearer_Returns401(t *testing.T) { + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + // HasAnyLiveTokenGlobal returns 1 — platform has at least one enrolled workspace. + mock.ExpectQuery(hasAnyLiveTokenGlobalQuery). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := gin.New() + r.GET("/admin/secrets", AdminAuth(mockDB), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"secrets": []string{"GITHUB_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"}}) + }) + + w := httptest.NewRecorder() + // C10 attack: no Authorization header — must not leak secrets. + req, _ := http.NewRequest(http.MethodGet, "/admin/secrets", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("C10 no-bearer: expected 401, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestAdminAuth_C11_PostNoBearer_Returns401 — C11 critical path: env poisoning +// via POST /admin/secrets without auth must be rejected. +func TestAdminAuth_C11_PostNoBearer_Returns401(t *testing.T) { + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + mock.ExpectQuery(hasAnyLiveTokenGlobalQuery). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := gin.New() + r.POST("/admin/secrets", AdminAuth(mockDB), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/admin/secrets", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("C11 POST no-bearer: expected 401, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestAdminAuth_C11_DeleteNoBearer_Returns401 — C11: DELETE /admin/secrets/:key +// without auth must be rejected (env poisoning → RCE on agent restart). +func TestAdminAuth_C11_DeleteNoBearer_Returns401(t *testing.T) { + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + mock.ExpectQuery(hasAnyLiveTokenGlobalQuery). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + r := gin.New() + r.DELETE("/admin/secrets/:key", AdminAuth(mockDB), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, "/admin/secrets/CLAUDE_CODE_OAUTH_TOKEN", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("C11 DELETE no-bearer: expected 401, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestAdminAuth_ValidBearer_Passes — a valid bearer token (from any workspace) +// must be accepted for admin routes. +func TestAdminAuth_ValidBearer_Passes(t *testing.T) { + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + adminToken := "admin-bearer-token-from-any-workspace" + tokenHash := sha256.Sum256([]byte(adminToken)) + + // HasAnyLiveTokenGlobal: tokens exist. + mock.ExpectQuery(hasAnyLiveTokenGlobalQuery). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + // ValidateAnyToken SELECT — token matches a live row. + mock.ExpectQuery(validateAnyTokenSelectQuery). + WithArgs(tokenHash[:]). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("tok-admin-1")) + + // Best-effort last_used_at UPDATE. + mock.ExpectExec(validateTokenUpdateQuery). + WithArgs("tok-admin-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + r := gin.New() + r.GET("/admin/secrets", AdminAuth(mockDB), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/admin/secrets", nil) + req.Header.Set("Authorization", "Bearer "+adminToken) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("C10 valid bearer: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} + +// TestAdminAuth_InvalidBearer_Returns401 — wrong token must not grant admin access. +func TestAdminAuth_InvalidBearer_Returns401(t *testing.T) { + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer mockDB.Close() + + wrongToken := "completely-wrong-token" + wrongHash := sha256.Sum256([]byte(wrongToken)) + + mock.ExpectQuery(hasAnyLiveTokenGlobalQuery). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + // ValidateAnyToken SELECT — no matching row. + mock.ExpectQuery(validateAnyTokenSelectQuery). + WithArgs(wrongHash[:]). + WillReturnRows(sqlmock.NewRows([]string{"id"})) // empty → ErrNoRows + + r := gin.New() + r.GET("/admin/secrets", AdminAuth(mockDB), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/admin/secrets", nil) + req.Header.Set("Authorization", "Bearer "+wrongToken) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("C10 invalid bearer: expected 401, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet sqlmock expectations: %v", err) + } +} diff --git a/platform/internal/router/router.go b/platform/internal/router/router.go index bf3bd583..949512ee 100644 --- a/platform/internal/router/router.go +++ b/platform/internal/router/router.go @@ -62,20 +62,22 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi // Scrape with: curl http://localhost:8080/metrics r.GET("/metrics", metrics.Handler()) - // Workspaces read + position-patch — left open for the canvas browser frontend - // which has no bearer token. C19 (GET /workspaces exposes topology) requires a - // canvas service-token refactor and is tracked as a follow-up issue. - r.GET("/workspaces", wh.List) + // Workspace read-only endpoints accessible without an explicit workspace ID. + // /workspaces/:id and PATCH (position-persist) remain open for the canvas + // browser frontend which does not carry a bearer token in those calls. r.GET("/workspaces/:id", wh.Get) r.PATCH("/workspaces/:id", wh.Update) - // C20 + C18-adjacent: mutating workspace operations require any valid workspace - // bearer token (AdminAuth — same fail-open bootstrap contract as global secrets). - // Blocks: mass deletion (C20), unauthenticated workspace creation. - // Canvas Create Workspace dialog passes through because no global tokens exist - // on a fresh install; once any workspace is online the dialog requires auth. + // C1 + C20 + C18-adjacent: workspace list and mutating operations all gated + // behind AdminAuth — any valid workspace bearer token grants access. + // Fail-open when no tokens exist anywhere (fresh install / pre-Phase-30). + // This blocks: + // C1 — unauthenticated GET /workspaces (workspace topology exposure) + // C20 — unauthenticated DELETE /workspaces/:id (mass-deletion attack) + // unauthenticated POST /workspaces (workspace creation) { wsAdmin := r.Group("", middleware.AdminAuth(db.DB)) + wsAdmin.GET("/workspaces", wh.List) wsAdmin.POST("/workspaces", wh.Create) wsAdmin.DELETE("/workspaces/:id", wh.Delete) }