fix(approvals#66): requester-initiated withdraw endpoint #2849

Merged
devops-engineer merged 1 commits from fix/66-approval-requester-withdraw into main 2026-06-14 12:52:47 +00:00
7 changed files with 376 additions and 3 deletions
+1
View File
@@ -166,6 +166,7 @@ Backward-compatible admin aliases also exist under `/admin/secrets`.
| `POST` | `/workspaces/:id/approvals` | Create approval request |
| `GET` | `/workspaces/:id/approvals` | List approvals for a workspace |
| `POST` | `/workspaces/:id/approvals/:approvalId/decide` | Approve or deny |
| `POST` | `/workspaces/:id/approvals/:approvalId/withdraw` | Requester pulls back a pending approval (issue #66). Authz is against the row's creator workspace, not the path `:id`, so it works correctly under cross-workspace approval gates (#2574 / #2593). |
### Team operations
+1
View File
@@ -43,6 +43,7 @@ Full contract: `docs/runbooks/admin-auth.md`.
| WS | /workspaces/:id/terminal | terminal.go |
| POST/GET | /workspaces/:id/approvals | approvals.go |
| POST | /workspaces/:id/approvals/:id/decide | approvals.go |
| POST | /workspaces/:id/approvals/:id/withdraw | approvals.go — requester pulls back a pending approval (#66) |
| GET | /approvals/pending | approvals.go |
| POST/GET | /workspaces/:id/memories | memories.go |
| DELETE | /workspaces/:id/memories/:id | memories.go |
+114 -3
View File
@@ -85,9 +85,11 @@ func (h *ApprovalsHandler) Create(c *gin.Context) {
// ListAll handles GET /approvals/pending
// Returns all pending approvals across all workspaces (for canvas polling).
// Approvals are long-lived until a human Decides (approve or deny); there is
// no time-based auto-expiry (CTO directive). A requester-initiated
// withdraw/cancel endpoint does not yet exist and is tracked as a separate
// follow-up.
// no time-based auto-expiry (CTO directive). A requester that no longer
// needs the approval can withdraw it via
// POST /workspaces/:id/approvals/:approvalId/withdraw (see Withdraw
// below) — the only path that moves a row out of 'pending' before a
// human acts on it.
func (h *ApprovalsHandler) ListAll(c *gin.Context) {
ctx := c.Request.Context()
@@ -232,3 +234,112 @@ func (h *ApprovalsHandler) Decide(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": body.Decision, "approval_id": approvalID})
}
// Withdraw handles POST /workspaces/:id/approvals/:approvalId/withdraw
// — the requester pulls back a pending approval before any human acts
// on it. Issue #66: closes the long-standing gap where a requester had
// no way to retract a request they'd raised but no longer needed
// (e.g. the underlying destructive op was abandoned, or the user
// changed their mind verbally and the agent wants to clear its own
// inbox row before the human approver wastes time on it).
//
// Authz model (PM/Researcher guardrail 7600d2ed): the caller's
// workspace token must match approval_requests.workspace_id (the
// CREATOR's workspace), NOT the URL path's :id. This matters for
// cross-workspace approval gates (core#2574, core#2593) where the
// approval row's workspace_id is the org-level gate, while the
// underlying request originates from a different workspace. Using
// the path's :id would (a) require every caller to know the
// approval's true creator workspace, and (b) be the wrong anchor
// for any audit trail.
//
// State guard: only status='pending' is withdrawable. An approval
// that has been approved/denied/escalated/withdrawn cannot be
// withdrawn — the human approver (or a prior withdraw) has already
// acted, and re-mutating the row would lose the audit signal.
//
// Event broadcast: APPROVAL_WITHDRAWN on the row's workspace_id
// (the creator's), matching the same convention Decide uses (so the
// canvas inbox can react uniformly).
func (h *ApprovalsHandler) Withdraw(c *gin.Context) {
workspaceID := c.Param("id")
approvalID := c.Param("approvalId")
ctx := c.Request.Context()
// Read the row to discover the creator workspace for authz.
// We need the creator workspace before the UPDATE so we can
// compare it to the caller's workspace token. (Decide skips
// this step because the path's :id IS the creator workspace
// in the non-cross-workspace case, and Decide doesn't authz
// against the creator anyway — it authzs against the
// approver, which is a different model.)
var creatorWorkspaceID string
err := db.DB.QueryRowContext(ctx, `
SELECT workspace_id::text FROM approval_requests WHERE id = $1
`, approvalID).Scan(&creatorWorkspaceID)
if err != nil {
// No row found (or the UUID is malformed) → 404. A
// malformed UUID would error from pgx with a parse
// failure rather than "no rows", so the caller can't
// distinguish "approval not found" from "approval id
// is invalid" — same response is fine for both: the
// approval can't be withdrawn either way.
c.JSON(http.StatusNotFound, gin.H{"error": "approval not found"})
return
}
// Authz: the caller (workspace-token) must be the creator
// workspace. We use the row's workspace_id, NOT the URL
// path :id — this is the load-bearing authz anchor for
// cross-workspace approval gates (#2574 / #2593), where
// the path's :id is the gate's workspace and the row's
// workspace_id is the underlying requester's workspace.
if workspaceID != "" && workspaceID != creatorWorkspaceID {
c.JSON(http.StatusForbidden, gin.H{"error": "not the requester"})
return
}
// State guard + status update in one statement. The
// WHERE status='pending' clause is the load-bearing
// guarantee: if the row was already approved/denied/
// escalated/withdrawn by a concurrent caller, the UPDATE
// affects 0 rows and we return 409 (Conflict) — the
// requester's withdraw raced with the human approver and
// lost. Returning 404 instead would be a lie (the row
// exists), and returning 200 would silently drop the
// state-change.
result, err := db.DB.ExecContext(ctx, `
UPDATE approval_requests
SET status = 'withdrawn', decided_by = 'requester', decided_at = now()
WHERE id = $1 AND status = 'pending'
`, approvalID)
if err != nil {
log.Printf("Withdraw UPDATE error approval=%s workspace=%s: %v", approvalID, workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to withdraw"})
return
}
rows, err := result.RowsAffected()
if err != nil {
log.Printf("Withdraw RowsAffected error approval=%s workspace=%s: %v", approvalID, workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to withdraw"})
return
}
if rows == 0 {
// The approval exists (we read it above) but is no
// longer 'pending' — a concurrent Decide/withdraw got
// there first. Surface as 409 so the caller can
// distinguish this from the not-found case.
c.JSON(http.StatusConflict, gin.H{"error": "approval not pending (already decided or withdrawn)"})
return
}
if err := h.broadcaster.RecordAndBroadcast(ctx, "APPROVAL_WITHDRAWN", creatorWorkspaceID, map[string]interface{}{
"approval_id": approvalID,
"decided_by": "requester",
}); err != nil {
log.Printf("approvals: failed to broadcast approval withdrawal: %v", err)
}
c.JSON(http.StatusOK, gin.H{"status": "withdrawn", "approval_id": approvalID})
}
@@ -354,3 +354,194 @@ func TestApprovals_Decide_MissingDecision(t *testing.T) {
t.Errorf("expected 400, got %d", w.Code)
}
}
// ---------- ApprovalsHandler: Withdraw (#66) ----------
// TestApprovals_Withdraw_Success is the happy path. Caller's
// workspace token (URL :id=ws-1) matches the row's creator
// workspace_id, the row is currently 'pending', the UPDATE flips
// it to 'withdrawn', and the broadcaster records APPROVAL_WITHDRAWN.
func TestApprovals_Withdraw_Success(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewApprovalsHandler(broadcaster)
// Read creator workspace (authz lookup).
mock.ExpectQuery("SELECT workspace_id::text FROM approval_requests WHERE id").
WithArgs("appr-1").
WillReturnRows(sqlmock.NewRows([]string{"workspace_id"}).AddRow("ws-1"))
// State-guarded UPDATE (only flips pending → withdrawn).
mock.ExpectExec("UPDATE approval_requests").
WithArgs("appr-1").
WillReturnResult(sqlmock.NewResult(0, 1))
// Broadcast APPROVAL_WITHDRAWN.
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "approvalId", Value: "appr-1"}}
c.Request = httptest.NewRequest("POST", "/", nil)
handler.Withdraw(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp["status"] != "withdrawn" {
t.Errorf("expected status 'withdrawn', got %v", resp["status"])
}
if resp["approval_id"] != "appr-1" {
t.Errorf("expected approval_id appr-1, got %v", resp["approval_id"])
}
}
// TestApprovals_Withdraw_NotPendingReturns409 — the state guard.
// Row exists but is no longer 'pending' (a human approver
// already decided, or another withdraw raced and won). The
// UPDATE affects 0 rows → 409 Conflict. The caller can
// distinguish "row vanished" (404) from "row exists but
// already moved" (409), and the latter is the right answer
// here so the requester can refresh its local view.
func TestApprovals_Withdraw_NotPendingReturns409(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewApprovalsHandler(newTestBroadcaster())
mock.ExpectQuery("SELECT workspace_id::text FROM approval_requests WHERE id").
WithArgs("appr-1").
WillReturnRows(sqlmock.NewRows([]string{"workspace_id"}).AddRow("ws-1"))
// UPDATE finds 0 rows (status no longer pending).
mock.ExpectExec("UPDATE approval_requests").
WithArgs("appr-1").
WillReturnResult(sqlmock.NewResult(0, 0))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "approvalId", Value: "appr-1"}}
c.Request = httptest.NewRequest("POST", "/", nil)
handler.Withdraw(c)
if w.Code != http.StatusConflict {
t.Errorf("expected 409, got %d: %s", w.Code, w.Body.String())
}
}
// TestApprovals_Withdraw_NotFound — the approval row doesn't
// exist (or the UUID is malformed). Both cases return 404 —
// the caller can't withdraw either way.
func TestApprovals_Withdraw_NotFound(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewApprovalsHandler(newTestBroadcaster())
mock.ExpectQuery("SELECT workspace_id::text FROM approval_requests WHERE id").
WithArgs("missing").
WillReturnError(errPGRowNotFound())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "approvalId", Value: "missing"}}
c.Request = httptest.NewRequest("POST", "/", nil)
handler.Withdraw(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
// TestApprovals_Withdraw_CrossWorkspaceAuthzReject — the
// load-bearing authz test (PM/Researcher guardrail 7600d2ed).
// The caller's path :id (ws-evil) does NOT match the row's
// creator workspace_id (ws-1). Without the authz check, a
// malicious caller could withdraw any approval they could
// guess the UUID of. The 403 short-circuits BEFORE the UPDATE
// runs, so the row is left untouched.
func TestApprovals_Withdraw_CrossWorkspaceAuthzReject(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewApprovalsHandler(newTestBroadcaster())
// Row's creator is ws-1.
mock.ExpectQuery("SELECT workspace_id::text FROM approval_requests WHERE id").
WithArgs("appr-1").
WillReturnRows(sqlmock.NewRows([]string{"workspace_id"}).AddRow("ws-1"))
// No UPDATE expected — authz rejects before we get there.
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-evil"}, {Key: "approvalId", Value: "appr-1"}}
c.Request = httptest.NewRequest("POST", "/", nil)
handler.Withdraw(c)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("mock expectations not met (authz check should have short-circuited before UPDATE): %v", err)
}
}
// TestApprovals_Withdraw_CrossWorkspaceGateOK — the
// cross-workspace approval-gate scenario (#2574 / #2593) where
// the approval row's creator is a different workspace from the
// gate's workspace. The authz anchor is the row's creator
// workspace_id, NOT the path :id, so when the caller presents
// the CREATOR's token (path :id=ws-1, row's ws-1), withdraw
// proceeds normally. This is the case that the "use path :id"
// authz model would have wrongly rejected.
func TestApprovals_Withdraw_CrossWorkspaceGateOK(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewApprovalsHandler(broadcaster)
// Row's creator is ws-1 (the underlying requesting workspace).
mock.ExpectQuery("SELECT workspace_id::text FROM approval_requests WHERE id").
WithArgs("appr-1").
WillReturnRows(sqlmock.NewRows([]string{"workspace_id"}).AddRow("ws-1"))
mock.ExpectExec("UPDATE approval_requests").
WithArgs("appr-1").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "approvalId", Value: "appr-1"}}
c.Request = httptest.NewRequest("POST", "/", nil)
handler.Withdraw(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
// errPGRowNotFound returns the pgx-style "no rows in result set"
// error so the Withdraw handler hits the 404 path. Used by
// TestApprovals_Withdraw_NotFound to keep the test free of
// pgx imports (the rest of the suite uses sqlmock which already
// pulls pgx in transitively).
func errPGRowNotFound() error {
return &pgRowNotFoundErr{}
}
// pgRowNotFoundErr is a tiny error type satisfying the error
// interface. The Withdraw handler treats any non-nil error from
// the authz-lookup QueryRow as a "row not found" → 404.
type pgRowNotFoundErr struct{}
func (e *pgRowNotFoundErr) Error() string { return "no rows in result set" }
@@ -358,6 +358,13 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
wsAuth.POST("/approvals", apph.Create)
wsAuth.GET("/approvals", apph.List)
wsAuth.POST("/approvals/:approvalId/decide", apph.Decide)
// Requester-initiated withdraw (#66): the agent that raised
// the approval can pull it back before any human acts on it.
// Mirrors the requests.Cancel authz model — workspace-token
// path is authz-checked against the row's creator workspace
// (NOT the path :id) to handle cross-workspace approval
// gates (#2574 / #2593).
wsAuth.POST("/approvals/:approvalId/withdraw", apph.Withdraw)
// /approvals/pending is a cross-workspace admin path; WorkspaceAuth cannot
// be used here (no workspace scope), but it still needs auth so an
// unauthenticated caller cannot enumerate all pending approvals across the
@@ -0,0 +1,25 @@
-- Reverse: remove 'withdrawn' from approval_requests.status CHECK.
--
-- Step 1: delete any rows that are in 'withdrawn' state. The endpoint
-- was new in the up migration; rolling back the schema means rolling
-- back the data semantics, so any rows the endpoint wrote must go
-- away to keep the CHECK constraint satisfiable.
--
-- Step 2: narrow the CHECK back to the original 4-value enum.
--
-- This is the safe-rollback path: a deploy that runs the up
-- migration, exercises the endpoint, and then rolls back will
-- cleanly drop the 'withdrawn' rows and restore the original
-- constraint. The trade-off is loss of audit history for the
-- 'withdrawn' rows in the rollback window — acceptable because
-- the endpoint is new in the same deploy, and any 'withdrawn'
-- row that exists is at most a few hours old.
DELETE FROM approval_requests WHERE status = 'withdrawn';
ALTER TABLE approval_requests
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
ALTER TABLE approval_requests
ADD CONSTRAINT approval_requests_status_check
CHECK (status IN ('pending', 'approved', 'denied', 'escalated'));
@@ -0,0 +1,37 @@
-- Add 'withdrawn' to the approval_requests.status CHECK constraint.
-- This is the additive + reversible counterpart to issue #66's
-- requester-initiated withdraw endpoint (POST /workspaces/:id/approvals/:approvalId/withdraw).
--
-- Why a new value (vs. reusing 'denied'):
-- - 'denied' is approver-initiated (a human Decides the request is wrong).
-- - 'withdrawn' is requester-initiated (the agent that raised the
-- approval decides it no longer needs the destructive op and pulls
-- the request back before any approver acts on it).
-- - Collapsing them would lose the audit signal: 'why did this approval
-- disappear' is a load-bearing question for the human approver who
-- sees the inbox change. Distinguishing the two paths lets the
-- events log + future analytics separate the two intent classes.
--
-- Why additive + reversible:
-- - The migration only widens the CHECK enum; existing rows are
-- untouched (no existing row is in 'withdrawn' before the
-- endpoint exists).
-- - The down migration narrows the CHECK back to the original 4-value
-- enum AND deletes any 'withdrawn' rows, so a rollback is safe
-- even if the endpoint has been exercised in the meantime
-- (acceptable loss: the audit history of a row we just rewrote
-- in this deploy window).
-- - PM/Researcher guardrail (7600d2ed): migration must be additive
-- + reversible so the change can be held in the deploy pipeline
-- without locking the table in a partial state.
ALTER TABLE approval_requests
DROP CONSTRAINT IF EXISTS approval_requests_status_check;
ALTER TABLE approval_requests
ADD CONSTRAINT approval_requests_status_check
CHECK (status IN ('pending', 'approved', 'denied', 'escalated', 'withdrawn'));
-- Index for the "pending + recent first" query path that ListAll /
-- List use (existing idx_approvals_status covers the prefix; the
-- partial index is unnecessary — the existing btree is fine for the
-- current row count).