diff --git a/platform/internal/handlers/org_plugin_allowlist.go b/platform/internal/handlers/org_plugin_allowlist.go new file mode 100644 index 00000000..99672b03 --- /dev/null +++ b/platform/internal/handlers/org_plugin_allowlist.go @@ -0,0 +1,254 @@ +package handlers + +import ( + "context" + "database/sql" + "fmt" + "log" + "net/http" + "time" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/gin-gonic/gin" +) + +// resolveOrgID returns the effective org ID for a workspace: the parent_id +// when one exists, or the workspace's own ID when it is the org root. +// Returns an empty string if the workspace is not found. +func resolveOrgID(ctx context.Context, workspaceID string) (string, error) { + var parentID sql.NullString + err := db.DB.QueryRowContext(ctx, + `SELECT parent_id FROM workspaces WHERE id = $1`, + workspaceID, + ).Scan(&parentID) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", err + } + if parentID.Valid && parentID.String != "" { + return parentID.String, nil + } + return workspaceID, nil +} + +// checkOrgPluginAllowlist returns (true, reason) when the plugin is blocked +// by the org's allowlist, or (false, "") when the install is permitted. +// +// Semantics: +// - No allowlist rows for this org → allow-all (backward compat). +// - Allowlist exists and plugin is on it → allowed. +// - Allowlist exists and plugin is NOT on it → blocked (403). +// - DB errors → fail-open with a log (don't block installs on DB hiccup). +func checkOrgPluginAllowlist(ctx context.Context, workspaceID, pluginName string) (blocked bool, reason string) { + orgID, err := resolveOrgID(ctx, workspaceID) + if err != nil { + log.Printf("allowlist: resolveOrgID(%s) failed: %v — allowing install", workspaceID, err) + return false, "" + } + if orgID == "" { + return false, "" // workspace not found; let later checks handle it + } + + var allowed bool + err = db.DB.QueryRowContext(ctx, ` + SELECT EXISTS( + SELECT 1 FROM org_plugin_allowlist + WHERE org_id = $1 AND plugin_name = $2 + ) + `, orgID, pluginName).Scan(&allowed) + if err != nil { + log.Printf("allowlist: existence check failed (org=%s plugin=%s): %v — allowing install", orgID, pluginName, err) + return false, "" + } + if allowed { + return false, "" // explicitly on the allowlist + } + + // Check whether an allowlist exists at all. Empty allowlist = allow-all. + var count int + if err := db.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM org_plugin_allowlist WHERE org_id = $1`, + orgID, + ).Scan(&count); err != nil { + log.Printf("allowlist: count check failed (org=%s): %v — allowing install", orgID, err) + return false, "" + } + if count == 0 { + return false, "" // no allowlist configured — allow-all + } + + return true, fmt.Sprintf("plugin %q is not in the org allowlist", pluginName) +} + +// OrgPluginAllowlistHandler manages the per-org plugin governance registry. +type OrgPluginAllowlistHandler struct{} + +// NewOrgPluginAllowlistHandler constructs an OrgPluginAllowlistHandler. +func NewOrgPluginAllowlistHandler() *OrgPluginAllowlistHandler { + return &OrgPluginAllowlistHandler{} +} + +// allowlistEntry is the JSON shape for a single allowlist record. +type allowlistEntry struct { + PluginName string `json:"plugin_name"` + EnabledBy string `json:"enabled_by"` + EnabledAt time.Time `json:"enabled_at"` +} + +// putAllowlistRequest is the request body for PUT /orgs/:id/plugins/allowlist. +// Plugins holds the complete desired allowlist; the handler replaces the +// current entries atomically. An empty slice clears the allowlist (allow-all). +type putAllowlistRequest struct { + Plugins []string `json:"plugins"` + EnabledBy string `json:"enabled_by"` // workspace ID of the admin performing the change +} + +// GetAllowlist handles GET /orgs/:id/plugins/allowlist. +// +// Returns the current allowlist for the org workspace identified by :id. +// An empty array means no allowlist is configured (allow-all). Auth: AdminAuth. +func (h *OrgPluginAllowlistHandler) GetAllowlist(c *gin.Context) { + orgID := c.Param("id") + ctx := c.Request.Context() + + // Verify the org workspace exists. + var exists bool + if err := db.DB.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1)`, + orgID, + ).Scan(&exists); err != nil { + log.Printf("allowlist: org check failed for %s: %v", orgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify org"}) + return + } + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "org not found"}) + return + } + + rows, err := db.DB.QueryContext(ctx, ` + SELECT plugin_name, enabled_by, enabled_at + FROM org_plugin_allowlist + WHERE org_id = $1 + ORDER BY plugin_name + `, orgID) + if err != nil { + log.Printf("allowlist: query failed for org %s: %v", orgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch allowlist"}) + return + } + defer rows.Close() + + entries := make([]allowlistEntry, 0) + for rows.Next() { + var e allowlistEntry + if err := rows.Scan(&e.PluginName, &e.EnabledBy, &e.EnabledAt); err != nil { + log.Printf("allowlist: scan error for org %s: %v", orgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read allowlist"}) + return + } + entries = append(entries, e) + } + if err := rows.Err(); err != nil { + log.Printf("allowlist: rows error for org %s: %v", orgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read allowlist"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "org_id": orgID, + "plugins": entries, + "allow_all": len(entries) == 0, + }) +} + +// PutAllowlist handles PUT /orgs/:id/plugins/allowlist. +// +// Replaces the org's allowlist atomically with the supplied plugin names. +// Sending an empty plugins array clears the allowlist (reverts to allow-all). +// Auth: AdminAuth. +func (h *OrgPluginAllowlistHandler) PutAllowlist(c *gin.Context) { + orgID := c.Param("id") + ctx := c.Request.Context() + + var req putAllowlistRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.EnabledBy == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "enabled_by is required"}) + return + } + + // Validate each plugin name for safety before touching the DB. + for _, name := range req.Plugins { + if err := validatePluginName(name); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid plugin name", + "plugin_name": name, + "detail": err.Error(), + }) + return + } + } + + // Verify the org workspace exists. + var exists bool + if err := db.DB.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1)`, + orgID, + ).Scan(&exists); err != nil { + log.Printf("allowlist: org check failed for %s: %v", orgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify org"}) + return + } + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "org not found"}) + return + } + + // Replace atomically: delete all current entries, then insert the new set. + tx, err := db.DB.BeginTx(ctx, nil) + if err != nil { + log.Printf("allowlist: begin tx failed for org %s: %v", orgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"}) + return + } + defer tx.Rollback() //nolint:errcheck // superseded by Commit on success path + + if _, err := tx.ExecContext(ctx, + `DELETE FROM org_plugin_allowlist WHERE org_id = $1`, + orgID, + ); err != nil { + log.Printf("allowlist: delete failed for org %s: %v", orgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update allowlist"}) + return + } + + for _, name := range req.Plugins { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO org_plugin_allowlist (org_id, plugin_name, enabled_by) + VALUES ($1, $2, $3) + ON CONFLICT (org_id, plugin_name) DO NOTHING + `, orgID, name, req.EnabledBy); err != nil { + log.Printf("allowlist: insert %q failed for org %s: %v", name, orgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update allowlist"}) + return + } + } + + if err := tx.Commit(); err != nil { + log.Printf("allowlist: commit failed for org %s: %v", orgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit allowlist update"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "org_id": orgID, + "plugins": req.Plugins, + "allow_all": len(req.Plugins) == 0, + }) +} diff --git a/platform/internal/handlers/org_plugin_allowlist_test.go b/platform/internal/handlers/org_plugin_allowlist_test.go new file mode 100644 index 00000000..bcc42d05 --- /dev/null +++ b/platform/internal/handlers/org_plugin_allowlist_test.go @@ -0,0 +1,555 @@ +package handlers + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" +) + +// ─── helpers ─────────────────────────────────────────────────────────────── + +func newAllowlistGET(orgID string) (*httptest.ResponseRecorder, *gin.Context) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: orgID}} + c.Request = httptest.NewRequest(http.MethodGet, "/orgs/"+orgID+"/plugins/allowlist", nil) + return w, c +} + +func newAllowlistPUT(orgID string, body interface{}) (*httptest.ResponseRecorder, *gin.Context) { + b, _ := json.Marshal(body) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: orgID}} + c.Request = httptest.NewRequest(http.MethodPut, "/orgs/"+orgID+"/plugins/allowlist", + bytes.NewReader(b)) + c.Request.Header.Set("Content-Type", "application/json") + return w, c +} + +// ─── GetAllowlist ────────────────────────────────────────────────────────── + +func TestGetAllowlist_OrgNotFound(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("org-missing"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + h := NewOrgPluginAllowlistHandler() + w, c := newAllowlistGET("org-missing") + h.GetAllowlist(c) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetAllowlist_DBErrorOnOrgCheck(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("org-1"). + WillReturnError(sql.ErrConnDone) + + h := NewOrgPluginAllowlistHandler() + w, c := newAllowlistGET("org-1") + h.GetAllowlist(c) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetAllowlist_Empty(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("org-1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectQuery(`SELECT plugin_name, enabled_by, enabled_at`). + WithArgs("org-1"). + WillReturnRows(sqlmock.NewRows([]string{"plugin_name", "enabled_by", "enabled_at"})) + + h := NewOrgPluginAllowlistHandler() + w, c := newAllowlistGET("org-1") + h.GetAllowlist(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + OrgID string `json:"org_id"` + Plugins []allowlistEntry `json:"plugins"` + AllowAll bool `json:"allow_all"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad JSON: %v", err) + } + if resp.OrgID != "org-1" { + t.Errorf("expected org_id=org-1, got %q", resp.OrgID) + } + if len(resp.Plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(resp.Plugins)) + } + if !resp.AllowAll { + t.Error("expected allow_all=true for empty list") + } +} + +func TestGetAllowlist_WithEntries(t *testing.T) { + mock := setupTestDB(t) + ts := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("org-1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectQuery(`SELECT plugin_name, enabled_by, enabled_at`). + WithArgs("org-1"). + WillReturnRows(sqlmock.NewRows([]string{"plugin_name", "enabled_by", "enabled_at"}). + AddRow("browser-automation", "admin-ws", ts). + AddRow("superpowers", "admin-ws", ts)) + + h := NewOrgPluginAllowlistHandler() + w, c := newAllowlistGET("org-1") + h.GetAllowlist(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + OrgID string `json:"org_id"` + Plugins []allowlistEntry `json:"plugins"` + AllowAll bool `json:"allow_all"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad JSON: %v", err) + } + if len(resp.Plugins) != 2 { + t.Fatalf("expected 2 plugins, got %d", len(resp.Plugins)) + } + if resp.Plugins[0].PluginName != "browser-automation" { + t.Errorf("expected first plugin=browser-automation, got %q", resp.Plugins[0].PluginName) + } + if resp.AllowAll { + t.Error("expected allow_all=false when list is non-empty") + } +} + +func TestGetAllowlist_DBErrorOnQuery(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("org-1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectQuery(`SELECT plugin_name, enabled_by, enabled_at`). + WithArgs("org-1"). + WillReturnError(sql.ErrConnDone) + + h := NewOrgPluginAllowlistHandler() + w, c := newAllowlistGET("org-1") + h.GetAllowlist(c) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} + +// ─── PutAllowlist ────────────────────────────────────────────────────────── + +func TestPutAllowlist_MissingEnabledBy(t *testing.T) { + setupTestDB(t) + + h := NewOrgPluginAllowlistHandler() + w, c := newAllowlistPUT("org-1", map[string]interface{}{ + "plugins": []string{"my-plugin"}, + // enabled_by intentionally omitted + }) + h.PutAllowlist(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPutAllowlist_InvalidPluginName(t *testing.T) { + setupTestDB(t) + + h := NewOrgPluginAllowlistHandler() + w, c := newAllowlistPUT("org-1", map[string]interface{}{ + "plugins": []string{"../../evil"}, + "enabled_by": "admin-ws", + }) + h.PutAllowlist(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid plugin name, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPutAllowlist_OrgNotFound(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("org-missing"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + h := NewOrgPluginAllowlistHandler() + w, c := newAllowlistPUT("org-missing", map[string]interface{}{ + "plugins": []string{"my-plugin"}, + "enabled_by": "admin-ws", + }) + h.PutAllowlist(c) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPutAllowlist_AddPlugins(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("org-1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectBegin() + mock.ExpectExec(`DELETE FROM org_plugin_allowlist`). + WithArgs("org-1"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(`INSERT INTO org_plugin_allowlist`). + WithArgs("org-1", "my-plugin", "admin-ws"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + h := NewOrgPluginAllowlistHandler() + w, c := newAllowlistPUT("org-1", map[string]interface{}{ + "plugins": []string{"my-plugin"}, + "enabled_by": "admin-ws", + }) + h.PutAllowlist(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + OrgID string `json:"org_id"` + Plugins []string `json:"plugins"` + AllowAll bool `json:"allow_all"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad JSON: %v", err) + } + if len(resp.Plugins) != 1 || resp.Plugins[0] != "my-plugin" { + t.Errorf("unexpected plugins: %v", resp.Plugins) + } + if resp.AllowAll { + t.Error("expected allow_all=false for non-empty plugins list") + } +} + +func TestPutAllowlist_ClearAllowlist(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("org-1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectBegin() + mock.ExpectExec(`DELETE FROM org_plugin_allowlist`). + WithArgs("org-1"). + WillReturnResult(sqlmock.NewResult(0, 3)) + // No INSERT expected — empty plugins slice. + mock.ExpectCommit() + + h := NewOrgPluginAllowlistHandler() + w, c := newAllowlistPUT("org-1", map[string]interface{}{ + "plugins": []string{}, + "enabled_by": "admin-ws", + }) + h.PutAllowlist(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + AllowAll bool `json:"allow_all"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad JSON: %v", err) + } + if !resp.AllowAll { + t.Error("expected allow_all=true after clearing all plugins") + } +} + +func TestPutAllowlist_MultiplePlugins(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("org-1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectBegin() + mock.ExpectExec(`DELETE FROM org_plugin_allowlist`). + WithArgs("org-1"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(`INSERT INTO org_plugin_allowlist`). + WithArgs("org-1", "browser-automation", "admin-ws"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`INSERT INTO org_plugin_allowlist`). + WithArgs("org-1", "superpowers", "admin-ws"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + h := NewOrgPluginAllowlistHandler() + w, c := newAllowlistPUT("org-1", map[string]interface{}{ + "plugins": []string{"browser-automation", "superpowers"}, + "enabled_by": "admin-ws", + }) + h.PutAllowlist(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPutAllowlist_InsertFails(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("org-1"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + mock.ExpectBegin() + mock.ExpectExec(`DELETE FROM org_plugin_allowlist`). + WithArgs("org-1"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(`INSERT INTO org_plugin_allowlist`). + WithArgs("org-1", "my-plugin", "admin-ws"). + WillReturnError(sql.ErrConnDone) + mock.ExpectRollback() + + h := NewOrgPluginAllowlistHandler() + w, c := newAllowlistPUT("org-1", map[string]interface{}{ + "plugins": []string{"my-plugin"}, + "enabled_by": "admin-ws", + }) + h.PutAllowlist(c) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 on insert failure, got %d: %s", w.Code, w.Body.String()) + } +} + +// ─── resolveOrgID ────────────────────────────────────────────────────────── + +func TestResolveOrgID_OrgRoot(t *testing.T) { + mock := setupTestDB(t) + + // workspace has no parent → it IS the org root + mock.ExpectQuery(`SELECT parent_id FROM workspaces WHERE id`). + WithArgs("ws-root"). + WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil)) + + got, err := resolveOrgID(context.Background(), "ws-root") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "ws-root" { + t.Errorf("expected ws-root, got %q", got) + } +} + +func TestResolveOrgID_WithParent(t *testing.T) { + mock := setupTestDB(t) + + // workspace has a parent → parent is the org root + mock.ExpectQuery(`SELECT parent_id FROM workspaces WHERE id`). + WithArgs("ws-child"). + WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow("ws-parent")) + + got, err := resolveOrgID(context.Background(), "ws-child") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "ws-parent" { + t.Errorf("expected ws-parent, got %q", got) + } +} + +func TestResolveOrgID_NotFound(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT parent_id FROM workspaces WHERE id`). + WithArgs("ws-ghost"). + WillReturnError(sql.ErrNoRows) + + got, err := resolveOrgID(context.Background(), "ws-ghost") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Errorf("expected empty string for not-found workspace, got %q", got) + } +} + +// ─── checkOrgPluginAllowlist ─────────────────────────────────────────────── + +func TestCheckOrgPluginAllowlist_AllowAll_EmptyList(t *testing.T) { + mock := setupTestDB(t) + + // resolveOrgID: no parent → ws-1 is org root + mock.ExpectQuery(`SELECT parent_id FROM workspaces WHERE id`). + WithArgs("ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil)) + + // plugin NOT in list + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("ws-1", "my-plugin"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + // count = 0 → allow-all + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM org_plugin_allowlist`). + WithArgs("ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + blocked, reason := checkOrgPluginAllowlist(context.Background(), "ws-1", "my-plugin") + if blocked { + t.Errorf("expected not blocked (allow-all), got blocked: %s", reason) + } +} + +func TestCheckOrgPluginAllowlist_Allowed_OnList(t *testing.T) { + mock := setupTestDB(t) + + // resolveOrgID: no parent + mock.ExpectQuery(`SELECT parent_id FROM workspaces WHERE id`). + WithArgs("ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil)) + + // plugin IS in the allowlist + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("ws-1", "my-plugin"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + blocked, reason := checkOrgPluginAllowlist(context.Background(), "ws-1", "my-plugin") + if blocked { + t.Errorf("expected not blocked (on list), got blocked: %s", reason) + } +} + +func TestCheckOrgPluginAllowlist_Blocked_NotOnList(t *testing.T) { + mock := setupTestDB(t) + + // resolveOrgID: no parent + mock.ExpectQuery(`SELECT parent_id FROM workspaces WHERE id`). + WithArgs("ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil)) + + // plugin NOT in the list + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("ws-1", "evil-plugin"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + // count > 0 → allowlist is active + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM org_plugin_allowlist`). + WithArgs("ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + + blocked, reason := checkOrgPluginAllowlist(context.Background(), "ws-1", "evil-plugin") + if !blocked { + t.Error("expected plugin to be blocked (not on non-empty allowlist)") + } + if reason == "" { + t.Error("expected non-empty reason when blocked") + } +} + +func TestCheckOrgPluginAllowlist_ChildWorkspace_UsesParentOrg(t *testing.T) { + mock := setupTestDB(t) + + // resolveOrgID: ws-child has parent ws-parent + mock.ExpectQuery(`SELECT parent_id FROM workspaces WHERE id`). + WithArgs("ws-child"). + WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow("ws-parent")) + + // allowlist check uses parent org ID (ws-parent) + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("ws-parent", "my-plugin"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + blocked, reason := checkOrgPluginAllowlist(context.Background(), "ws-child", "my-plugin") + if blocked { + t.Errorf("expected not blocked (on parent's allowlist), got blocked: %s", reason) + } +} + +func TestCheckOrgPluginAllowlist_FailOpen_OnResolveError(t *testing.T) { + mock := setupTestDB(t) + + // DB error during resolveOrgID → fail-open + mock.ExpectQuery(`SELECT parent_id FROM workspaces WHERE id`). + WithArgs("ws-1"). + WillReturnError(sql.ErrConnDone) + + blocked, _ := checkOrgPluginAllowlist(context.Background(), "ws-1", "any-plugin") + if blocked { + t.Error("expected fail-open (not blocked) on DB error during resolveOrgID") + } +} + +func TestCheckOrgPluginAllowlist_FailOpen_OnExistsError(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT parent_id FROM workspaces WHERE id`). + WithArgs("ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil)) + + // DB error on EXISTS check → fail-open + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("ws-1", "any-plugin"). + WillReturnError(sql.ErrConnDone) + + blocked, _ := checkOrgPluginAllowlist(context.Background(), "ws-1", "any-plugin") + if blocked { + t.Error("expected fail-open (not blocked) on DB error during EXISTS check") + } +} + +func TestCheckOrgPluginAllowlist_FailOpen_OnCountError(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT parent_id FROM workspaces WHERE id`). + WithArgs("ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil)) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("ws-1", "any-plugin"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + // DB error on COUNT check → fail-open + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM org_plugin_allowlist`). + WithArgs("ws-1"). + WillReturnError(sql.ErrConnDone) + + blocked, _ := checkOrgPluginAllowlist(context.Background(), "ws-1", "any-plugin") + if blocked { + t.Error("expected fail-open (not blocked) on DB error during COUNT check") + } +} diff --git a/platform/internal/handlers/plugins_install.go b/platform/internal/handlers/plugins_install.go index 5fbc8c04..b75d6ef6 100644 --- a/platform/internal/handlers/plugins_install.go +++ b/platform/internal/handlers/plugins_install.go @@ -63,6 +63,14 @@ func (h *PluginsHandler) Install(c *gin.Context) { // has already cleaned it up (and its returned result is nil). defer os.RemoveAll(result.StagedDir) + // Org plugin allowlist gate (#591). + // If the workspace's org has a non-empty allowlist, the plugin must be + // on it. An empty allowlist means allow-all (backward compat). + if blocked, reason := checkOrgPluginAllowlist(ctx, workspaceID, result.PluginName); blocked { + c.JSON(http.StatusForbidden, gin.H{"error": reason}) + return + } + if err := h.deliverToContainer(ctx, workspaceID, result); err != nil { var he *httpErr if errors.As(err, &he) { diff --git a/platform/internal/router/router.go b/platform/internal/router/router.go index e126728f..d41b653a 100644 --- a/platform/internal/router/router.go +++ b/platform/internal/router/router.go @@ -408,6 +408,16 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi // depth keeps the route behind AdminAuth regardless. r.POST("/org/import", middleware.AdminAuth(db.DB), orgh.Import) + // Org plugin allowlist — tool governance (#591). + // Both endpoints are admin-gated: reading the allowlist reveals approved + // tooling policy; writing it enforces org-level install governance. + { + allowlistAdmin := r.Group("", middleware.AdminAuth(db.DB)) + aplh := handlers.NewOrgPluginAllowlistHandler() + allowlistAdmin.GET("/orgs/:id/plugins/allowlist", aplh.GetAllowlist) + allowlistAdmin.PUT("/orgs/:id/plugins/allowlist", aplh.PutAllowlist) + } + // Channels (social integrations — Telegram, Slack, Discord, etc.) chh := handlers.NewChannelHandler(channelMgr) r.GET("/channels/adapters", chh.ListAdapters) diff --git a/platform/migrations/027_org_plugin_allowlist.down.sql b/platform/migrations/027_org_plugin_allowlist.down.sql new file mode 100644 index 00000000..cb86941d --- /dev/null +++ b/platform/migrations/027_org_plugin_allowlist.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS org_plugin_allowlist; diff --git a/platform/migrations/027_org_plugin_allowlist.up.sql b/platform/migrations/027_org_plugin_allowlist.up.sql new file mode 100644 index 00000000..f2d12353 --- /dev/null +++ b/platform/migrations/027_org_plugin_allowlist.up.sql @@ -0,0 +1,17 @@ +-- Per-org plugin allowlist for tool governance (#591). +-- When an org has at least one entry in this table, workspace agents may only +-- install plugins listed here. An empty allowlist means "allow all" (backward +-- compatible with existing deployments). +-- +-- org_id references the root/parent workspace that acts as the org anchor. +-- enabled_by records the workspace ID of the admin who added the entry. +CREATE TABLE IF NOT EXISTS org_plugin_allowlist ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + plugin_name TEXT NOT NULL, + enabled_by TEXT NOT NULL, + enabled_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS org_plugin_allowlist_org_plugin + ON org_plugin_allowlist(org_id, plugin_name);