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:
commit
652019afcc
254
platform/internal/handlers/org_plugin_allowlist.go
Normal file
254
platform/internal/handlers/org_plugin_allowlist.go
Normal 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,
|
||||
})
|
||||
}
|
||||
555
platform/internal/handlers/org_plugin_allowlist_test.go
Normal file
555
platform/internal/handlers/org_plugin_allowlist_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
1
platform/migrations/027_org_plugin_allowlist.down.sql
Normal file
1
platform/migrations/027_org_plugin_allowlist.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS org_plugin_allowlist;
|
||||
17
platform/migrations/027_org_plugin_allowlist.up.sql
Normal file
17
platform/migrations/027_org_plugin_allowlist.up.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user