Merge pull request #610 from Molecule-AI/feat/issue-591-org-plugin-allowlist

feat(platform): per-org plugin governance registry (allowlist)
This commit is contained in:
molecule-ai[bot] 2026-04-17 05:55:27 +00:00 committed by GitHub
commit 652019afcc
6 changed files with 845 additions and 0 deletions

View File

@ -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,
})
}

View File

@ -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")
}
}

View File

@ -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) {

View File

@ -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)

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS org_plugin_allowlist;

View File

@ -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);