feat(approval-gate): wire gateDestructive into live destructive handlers (Phase 4b) #2382

Merged
devops-engineer merged 1 commits from feat/platform-agent-gate-wiring into main 2026-06-06 23:25:29 +00:00
4 changed files with 152 additions and 9 deletions
@@ -29,6 +29,7 @@ import (
"fmt"
"log"
"net/http"
"os"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/approvals"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
@@ -50,7 +51,7 @@ func approvalRequestHash(workspaceID, action string, contextMap map[string]inter
// requireApproval returns (approved=true, consumedID) when a matching approval
// exists and was just consumed; otherwise it creates/reuses a pending approval
// and returns (false, pendingID). A non-nil error is a server error.
func requireApproval(ctx context.Context, b *events.Broadcaster, workspaceID string, action approvals.Action, reason string, contextMap map[string]interface{}) (bool, string, error) {
func requireApproval(ctx context.Context, b events.EventEmitter, workspaceID string, action approvals.Action, reason string, contextMap map[string]interface{}) (bool, string, error) {
hash := approvalRequestHash(workspaceID, string(action), contextMap)
// 1. Atomically consume an approved + unconsumed request, if one exists.
@@ -103,18 +104,25 @@ func requireApproval(ctx context.Context, b *events.Broadcaster, workspaceID str
// Broadcast to the canvas (the user-facing signal). For a platform agent the
// parent_id is NULL, so the requested-event on its own workspace IS the user
// prompt; ordinary workspaces also escalate to their parent.
if bErr := b.RecordAndBroadcast(ctx, string(events.EventApprovalRequested), workspaceID, map[string]interface{}{
"approval_id": approvalID,
"action": string(action),
"reason": reason,
}); bErr != nil {
log.Printf("approval_gate: broadcast requested failed (ws=%s): %v", workspaceID, bErr)
//
// b may be nil: stateless handlers (e.g. org-token mint — OrgTokenHandler is
// an empty struct with no broadcaster) still gate; they just can't push a
// live canvas event. The pending approval row is persisted regardless, so
// the request is never lost — only the notification is skipped.
if b != nil {
if bErr := b.RecordAndBroadcast(ctx, string(events.EventApprovalRequested), workspaceID, map[string]interface{}{
"approval_id": approvalID,
"action": string(action),
"reason": reason,
}); bErr != nil {
log.Printf("approval_gate: broadcast requested failed (ws=%s): %v", workspaceID, bErr)
}
}
var parentID *string
if pErr := db.DB.QueryRowContext(ctx, `SELECT parent_id FROM workspaces WHERE id = $1`, workspaceID).Scan(&parentID); pErr != nil {
log.Printf("approval_gate: parent lookup failed (ws=%s): %v", workspaceID, pErr)
}
if parentID != nil {
if parentID != nil && b != nil {
if bErr := b.RecordAndBroadcast(ctx, string(events.EventApprovalEscalated), *parentID, map[string]interface{}{
"approval_id": approvalID,
"from_workspace_id": workspaceID,
@@ -130,10 +138,26 @@ func requireApproval(ctx context.Context, b *events.Broadcaster, workspaceID str
// gateDestructive runs requireApproval for a gated action and, when approval is
// still pending, writes the 202 response and returns false (caller must stop).
// Returns true when the caller may proceed (action consumed an approval).
func gateDestructive(c *gin.Context, b *events.Broadcaster, workspaceID string, action approvals.Action, reason string, contextMap map[string]interface{}) bool {
func gateDestructive(c *gin.Context, b events.EventEmitter, workspaceID string, action approvals.Action, reason string, contextMap map[string]interface{}) bool {
if !approvals.IsGated(action) {
return true
}
// Scope (RFC platform-agent Phase 4b). Wiring is a one-liner in each
// destructive handler; the activation policy lives here, centrally, so it is
// uniform and testable:
// - default-OFF rollout flag, so the wiring is inert until an operator
// enables it (mirrors the 3a/3c default-off design and protects existing
// org-token automation from a surprise async-approval behaviour change);
// - only callers holding an ORG token are gated. The platform agent runs
// with MOLECULE_API_KEY=<org-admin token>, so the auth middleware sets
// org_token_id. Ordinary workspace-token agents and human CP-session
// operators (cp_session_actor — the approvers themselves) are NOT gated,
// so normal operation is byte-identical. This realises the file-header
// trust boundary ("anything holding an org-admin token still goes
// through the gate") without gating everyone.
if !destructiveGateEnabled() || !callerHoldsOrgToken(c) {
return true
}
approved, approvalID, err := requireApproval(c.Request.Context(), b, workspaceID, action, reason, contextMap)
if err != nil {
log.Printf("gateDestructive: %v (ws=%s action=%s)", err, workspaceID, action)
@@ -151,3 +175,22 @@ func gateDestructive(c *gin.Context, b *events.Broadcaster, workspaceID string,
}
return true
}
// destructiveGateEnabled is the default-off rollout flag for the org-level
// destructive-op approval gate. Inert until an operator sets
// MOLECULE_PLATFORM_APPROVAL_GATE=1 (or "true") — typically when the platform
// agent is deployed to the org. Keeps 4b's wiring shipped-but-dormant, matching
// the platform-agent feature's default-off posture (3a/3c).
func destructiveGateEnabled() bool {
v := os.Getenv("MOLECULE_PLATFORM_APPROVAL_GATE")
return v == "1" || v == "true"
}
// callerHoldsOrgToken reports whether the request authenticated with an org
// token (the auth middleware sets org_token_id, see middleware/wsauth_middleware.go).
// The platform agent uses an org-admin token; ordinary workspace-token agents
// and human CP sessions do not, so they bypass the gate entirely.
func callerHoldsOrgToken(c *gin.Context) bool {
_, ok := c.Get("org_token_id")
return ok
}
@@ -0,0 +1,76 @@
package handlers
// Phase 4b — unit coverage for the gate's activation SCOPE: the default-off
// rollout flag + org-token-only targeting. These exercise the short-circuit
// paths that return "proceed" BEFORE requireApproval, so they need no DB. The
// full flag-on + org-token + gated → 202 path is covered by the real-Postgres
// approval_gate_integration_test.go.
import (
"net/http/httptest"
"os"
"testing"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/approvals"
"github.com/gin-gonic/gin"
)
func TestDestructiveGateEnabled_DefaultOff(t *testing.T) {
os.Unsetenv("MOLECULE_PLATFORM_APPROVAL_GATE")
if destructiveGateEnabled() {
t.Fatal("gate must be OFF by default (no env)")
}
for _, v := range []string{"1", "true"} {
t.Setenv("MOLECULE_PLATFORM_APPROVAL_GATE", v)
if !destructiveGateEnabled() {
t.Errorf("%q must enable the gate", v)
}
}
t.Setenv("MOLECULE_PLATFORM_APPROVAL_GATE", "0")
if destructiveGateEnabled() {
t.Error(`"0" must keep the gate off`)
}
}
func TestCallerHoldsOrgToken(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
if callerHoldsOrgToken(c) {
t.Error("no org_token_id in context → must be false (workspace/CP caller)")
}
c.Set("org_token_id", "tok-abc")
if !callerHoldsOrgToken(c) {
t.Error("org_token_id set → must be true (platform-agent / org-admin caller)")
}
}
// gateDestructive must return true (proceed, no 202, no DB touch) whenever the
// scope excludes the call: non-gated action, flag off, or non-org-token caller.
func TestGateDestructive_ScopeShortCircuits(t *testing.T) {
gin.SetMode(gin.TestMode)
newCtx := func(orgToken bool) *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("DELETE", "/x", nil)
if orgToken {
c.Set("org_token_id", "tok")
}
return c
}
// flag OFF (default) + org-token + gated action → proceed.
os.Unsetenv("MOLECULE_PLATFORM_APPROVAL_GATE")
if !gateDestructive(newCtx(true), nil, "ws", approvals.ActionDeleteWorkspace, "r", nil) {
t.Error("flag off must proceed (gate dormant)")
}
// flag ON + NO org token (workspace agent / human CP session) → proceed.
t.Setenv("MOLECULE_PLATFORM_APPROVAL_GATE", "1")
if !gateDestructive(newCtx(false), nil, "ws", approvals.ActionDeleteWorkspace, "r", nil) {
t.Error("non-org-token caller must proceed (normal operation unchanged)")
}
// flag ON + org token + NON-gated action → proceed (IsGated short-circuit).
if !gateDestructive(newCtx(true), nil, "ws", approvals.Action("not_a_gated_action"), "r", nil) {
t.Error("non-gated action must proceed")
}
}
@@ -9,6 +9,7 @@ import (
"regexp"
"strings"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/approvals"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/audit"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/crypto"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
@@ -320,6 +321,18 @@ func (h *SecretsHandler) Set(c *gin.Context) {
return
}
// RFC platform-agent Phase 4b: gate org-token (platform-agent) secret writes
// behind human approval. The context includes the key so an approval for one
// secret cannot authorise writing another. No-op for ordinary callers and
// when the rollout flag is off (scoping lives in gateDestructive).
// SecretsHandler has no broadcaster, so pass nil — requireApproval persists
// the pending row regardless; only the live canvas push is skipped.
if !gateDestructive(c, nil, workspaceID, approvals.ActionSecretWrite,
"write secret "+body.Key,
map[string]interface{}{"workspace_id": workspaceID, "key": body.Key}) {
return
}
// Encrypt the value (AES-256-GCM if SECRETS_ENCRYPTION_KEY is set, plaintext otherwise)
encrypted, err := crypto.Encrypt([]byte(body.Value))
if err != nil {
@@ -16,6 +16,7 @@ import (
"strings"
"time"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/approvals"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/events"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/models"
@@ -356,6 +357,16 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
return
}
// RFC platform-agent Phase 4b: gate org-token (platform-agent) deletes behind
// human approval. No-op for ordinary workspace/CP-session callers and when
// the rollout flag is off (scoping lives in gateDestructive). Placed after
// the synchronous X-Confirm-Name guard, before any destruction.
if !gateDestructive(c, h.broadcaster, id, approvals.ActionDeleteWorkspace,
"delete workspace "+workspaceName,
map[string]interface{}{"workspace_id": id, "name": workspaceName}) {
return
}
// Check for children
rows, err := db.DB.QueryContext(ctx,
`SELECT id, name FROM workspaces WHERE parent_id = $1 AND status != 'removed'`, id)