Skip to content

Commit cd71af9

Browse files
committed
much better deploy approval request flow for approve - cache security key PIN so you only need to enter it once, and approve multiple racks just by touching the security key a few times
1 parent e096a91 commit cd71af9

5 files changed

Lines changed: 303 additions & 55 deletions

File tree

internal/cli/deploy_approvals_approve.go

Lines changed: 206 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@ func newDeployApprovalApproveCommand() *cobra.Command {
2727
Short: "Approve a deploy approval request",
2828
Long: `Approve a deploy approval request.
2929
30-
If no ID is provided, searches for the latest pending approval request matching the current git branch.
31-
Shows the request details and prompts for MFA code before approving.
30+
If no ID is provided, searches for pending approval requests matching the current git commit.
31+
Shows all matching requests and prompts once before approving all of them.
3232
3333
Examples:
3434
# Approve by ID
3535
cx deploy-approval approve abc123-def456-...
3636
37-
# Approve latest for current git branch (prompts for MFA)
37+
# Approve latest for current git commit (prompts for MFA)
3838
cx deploy-approval approve
3939
4040
# Approve latest for a specific branch
@@ -43,7 +43,7 @@ Examples:
4343
# Approve for a specific commit
4444
cx deploy-approval approve --commit abc123def
4545
46-
# Approve across multiple racks
46+
# Approve across multiple racks (one PIN entry, one touch per rack)
4747
cx deploy-approval approve --racks staging,us,eu`,
4848
Args: cobra.MaximumNArgs(1),
4949
RunE: SilenceOnError(func(cmd *cobra.Command, args []string) error {
@@ -53,8 +53,8 @@ Examples:
5353

5454
cmd.Flags().StringVar(&opts.racks, "racks", "", "Comma-separated list of racks to search")
5555
cmd.Flags().StringVarP(&opts.app, "app", "a", "", appFlagHelp)
56-
cmd.Flags().StringVar(&opts.branch, "branch", "", "Search by git branch (uses current branch if no ID given)")
57-
cmd.Flags().StringVar(&opts.commit, "commit", "", "Search by git commit hash")
56+
cmd.Flags().StringVar(&opts.branch, "branch", "", "Search by git branch")
57+
cmd.Flags().StringVar(&opts.commit, "commit", "", "Search by git commit hash (uses current commit by default)")
5858
cmd.Flags().StringVar(&opts.notes, "notes", "", "Optional notes for approval")
5959

6060
return cmd
@@ -66,7 +66,6 @@ func executeDeployApprovalApprove(cmd *cobra.Command, args []string, opts deploy
6666
return err
6767
}
6868

69-
// Resolve app name (auto-detect from .convox/app or directory)
7069
app, err := ResolveApp(opts.app)
7170
if err != nil {
7271
return err
@@ -111,57 +110,232 @@ func approveByID(cmd *cobra.Command, racks []string, publicID, notes string) err
111110
return fmt.Errorf("deploy approval request %s not found", publicID)
112111
}
113112

113+
type rackApproval struct {
114+
rack string
115+
req *deployApprovalRequest
116+
}
117+
114118
func approveBySearch(cmd *cobra.Command, racks []string, app, branch, commit, notes string) error {
115-
// Search each rack for a pending request
116-
for _, rack := range racks {
117-
req := findPendingRequest(cmd, rack, app, branch, commit)
118-
if req == nil {
119-
continue
119+
// Collect all requests from all racks (pending or approved, like show command)
120+
allRequests := collectAllRequests(cmd, racks, app, branch, commit)
121+
122+
if len(allRequests) == 0 {
123+
if branch != "" {
124+
return fmt.Errorf("no deploy approval request found for app %q branch %q", app, branch)
120125
}
126+
return fmt.Errorf("no deploy approval request found for app %q commit %q", app, commit)
127+
}
121128

122-
// Found a request - show details and prompt for confirmation
123-
showRack := len(racks) > 1
124-
fmt.Println("\n📋 Deploy Approval Request Found:")
125-
if err := printDeployApprovalDetails(req, rack, showRack); err != nil {
129+
// Display all requests
130+
fmt.Println()
131+
for i, r := range allRequests {
132+
if i > 0 {
133+
fmt.Println()
134+
}
135+
if err := printDeployApprovalDetails(r.req, r.rack, len(racks) > 1); err != nil {
126136
return err
127137
}
138+
}
139+
140+
// Filter to only pending requests
141+
var pending []rackApproval
142+
for _, r := range allRequests {
143+
if r.req.Status == "pending" {
144+
pending = append(pending, r)
145+
}
146+
}
147+
148+
// If no pending requests, nothing to approve
149+
if len(pending) == 0 {
150+
fmt.Println("\nAll requests are already approved.")
151+
return nil
152+
}
153+
154+
// Prompt for confirmation
155+
promptText := "\nPress Enter to approve"
156+
if len(pending) > 1 {
157+
promptText = fmt.Sprintf("\nPress Enter to approve %d pending request(s)", len(pending))
158+
}
159+
fmt.Print(promptText + " (or Ctrl+C to abort): ")
160+
161+
reader := bufio.NewReader(os.Stdin)
162+
if _, err := reader.ReadString('\n'); err != nil {
163+
return fmt.Errorf("aborted")
164+
}
165+
166+
// Approve each pending request, caching PIN after first one
167+
return approveAllRequests(cmd, pending, notes, len(racks) > 1)
168+
}
128169

129-
fmt.Print("\nPress Enter to approve (or Ctrl+C to abort): ")
130-
reader := bufio.NewReader(os.Stdin)
131-
if _, err := reader.ReadString('\n'); err != nil {
132-
return fmt.Errorf("aborted")
170+
func collectAllRequests(
171+
cmd *cobra.Command, racks []string, app, branch, commit string,
172+
) []rackApproval {
173+
var results []rackApproval
174+
for _, rack := range racks {
175+
// Try pending first, then approved (like show command)
176+
for _, status := range []string{"pending", "approved"} {
177+
req, found := searchForRequestInRack(cmd, rack, app, branch, commit, status)
178+
if found {
179+
results = append(results, rackApproval{rack: rack, req: req})
180+
break
181+
}
133182
}
183+
}
184+
return results
185+
}
186+
187+
func approveAllRequests(cmd *cobra.Command, pending []rackApproval, notes string, showRack bool) error {
188+
var cachedPIN string
189+
var successCount int
190+
191+
for i, p := range pending {
192+
printApprovalContext(cmd, p, i+1, len(pending))
134193

135-
approved, err := approveDeployRequest(cmd, rack, req.PublicID, notes)
194+
approved, pin, err := approveDeployRequestWithPIN(cmd, p.rack, p.req.PublicID, notes, cachedPIN)
136195
if err != nil {
137-
return err
196+
return fmt.Errorf("failed to approve request on rack %s: %w", p.rack, err)
197+
}
198+
199+
// Cache the PIN for subsequent approvals
200+
if cachedPIN == "" && pin != "" {
201+
cachedPIN = pin
138202
}
139203

140-
return printApprovalSuccess(cmd, approved, rack, showRack)
204+
if err := printApprovalSuccess(cmd, approved, p.rack, showRack); err != nil {
205+
return err
206+
}
207+
successCount++
141208
}
142209

143-
if branch != "" {
144-
return fmt.Errorf("no pending deploy approval request found for app %q branch %q", app, branch)
210+
if successCount > 1 {
211+
fmt.Printf("\n✅ Successfully approved %d requests\n", successCount)
145212
}
146-
return fmt.Errorf("no pending deploy approval request found for app %q commit %q", app, commit)
213+
214+
return nil
147215
}
148216

149-
func findPendingRequest(cmd *cobra.Command, rack, app, branch, commit string) *deployApprovalRequest {
150-
req, found := searchForRequestInRack(cmd, rack, app, branch, commit, "pending")
151-
if !found {
152-
return nil
217+
func printApprovalContext(cmd *cobra.Command, p rackApproval, current, total int) {
218+
out := cmd.ErrOrStderr()
219+
_, _ = fmt.Fprintln(out)
220+
221+
if total > 1 {
222+
_, _ = fmt.Fprintf(out, "Approving request %d of %d:\n", current, total)
223+
} else {
224+
_, _ = fmt.Fprintln(out, "Approving request:")
225+
}
226+
227+
_, _ = fmt.Fprintf(out, " Rack: %s\n", p.rack)
228+
_, _ = fmt.Fprintf(out, " ID: %s\n", p.req.PublicID)
229+
_, _ = fmt.Fprintf(out, " Message: %s\n", p.req.Message)
230+
if p.req.App != "" {
231+
_, _ = fmt.Fprintf(out, " App: %s\n", p.req.App)
232+
}
233+
if p.req.GitCommitHash != "" {
234+
_, _ = fmt.Fprintf(out, " Commit: %s\n", p.req.GitCommitHash)
235+
}
236+
if p.req.GitBranch != "" {
237+
_, _ = fmt.Fprintf(out, " Branch: %s\n", p.req.GitBranch)
153238
}
154-
return req
155239
}
156240

157241
func approveDeployRequest(cmd *cobra.Command, rack, requestID, notes string) (*deployApprovalRequest, error) {
242+
result, _, err := approveDeployRequestWithPIN(cmd, rack, requestID, notes, "")
243+
return result, err
244+
}
245+
246+
func approveDeployRequestWithPIN(
247+
cmd *cobra.Command, rack, requestID, notes, cachedPIN string,
248+
) (*deployApprovalRequest, string, error) {
158249
payload := map[string]interface{}{}
159250
if notes != "" {
160251
payload["notes"] = notes
161252
}
162253

163254
endpoint := fmt.Sprintf("/deploy-approval-requests/%s/approve", requestID)
164-
return postDeployApprovalRequest(cmd, rack, endpoint, payload)
255+
256+
// Get MFA auth with PIN caching
257+
mfaAuth, pinUsed, err := getMFAAuthWithPIN(cmd, rack, cachedPIN)
258+
if err != nil {
259+
return nil, "", err
260+
}
261+
262+
result, err := postDeployApprovalRequestWithMFA(cmd, rack, endpoint, payload, mfaAuth)
263+
if err != nil {
264+
return nil, "", err
265+
}
266+
267+
return result, pinUsed, nil
268+
}
269+
270+
func getMFAAuthWithPIN(cmd *cobra.Command, rack, cachedPIN string) (string, string, error) {
271+
if os.Getenv("RACK_GATEWAY_API_TOKEN") != "" {
272+
return "", "", nil
273+
}
274+
275+
gatewayURL, bearer, err := gatewayAuthInfo(rack)
276+
if err != nil {
277+
return "", "", err
278+
}
279+
280+
status, err := loadMFAStatus(gatewayURL, bearer)
281+
if err != nil {
282+
return "", "", err
283+
}
284+
285+
method, err := selectMFAMethod(status, rack)
286+
if err != nil {
287+
return "", "", err
288+
}
289+
290+
return collectMFAAuthWithPIN(cmd, gatewayURL, bearer, method, cachedPIN)
291+
}
292+
293+
func collectMFAAuthWithPIN(
294+
cmd *cobra.Command, baseURL, bearer string, method MFAMethodResponse, cachedPIN string,
295+
) (string, string, error) {
296+
out := cmd.ErrOrStderr()
297+
298+
switch method.Type {
299+
case "webauthn":
300+
// Only print the message if this is the first call (no cached PIN)
301+
if cachedPIN == "" {
302+
if err := writeLine(out, "Multi-factor authentication required (WebAuthn)."); err != nil {
303+
return "", "", err
304+
}
305+
}
306+
307+
assertionData, pinUsed, err := collectWebAuthnAssertionWithPIN(baseURL, bearer, cachedPIN)
308+
if err != nil {
309+
return "", "", fmt.Errorf("WebAuthn verification failed: %w", err)
310+
}
311+
312+
return "webauthn." + assertionData, pinUsed, nil
313+
314+
case "totp":
315+
if err := writeLine(out, "Multi-factor authentication required (TOTP)."); err != nil {
316+
return "", "", err
317+
}
318+
319+
code, err := promptMFACode()
320+
if err != nil {
321+
return "", "", err
322+
}
323+
324+
return "totp." + code, "", nil
325+
326+
default:
327+
return "", "", fmt.Errorf("unsupported MFA method for inline verification: %s", method.Type)
328+
}
329+
}
330+
331+
func postDeployApprovalRequestWithMFA(
332+
cmd *cobra.Command, rack, endpoint string, payload map[string]interface{}, mfaAuth string,
333+
) (*deployApprovalRequest, error) {
334+
var result deployApprovalRequest
335+
if err := gatewayRequestWithMFA(cmd, rack, "POST", endpoint, payload, &result, mfaAuth); err != nil {
336+
return nil, err
337+
}
338+
return &result, nil
165339
}
166340

167341
func printApprovalSuccess(cmd *cobra.Command, approved *deployApprovalRequest, rack string, showRack bool) error {

internal/cli/gateway_api_tokens.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,40 @@ func applyInlineMFA(bearer string, ctx *gatewayMFAContext) string {
233233
return bearer + "." + ctx.mfaAuth
234234
}
235235

236+
// gatewayRequestWithMFA performs a gateway request with pre-collected MFA auth.
237+
// This is used when MFA has already been collected (e.g., for batch approvals with PIN caching).
238+
func gatewayRequestWithMFA(
239+
_ *cobra.Command, rack, method, path string, body, out interface{}, mfaAuth string,
240+
) error {
241+
gatewayURL, bearer, err := gatewayAuthInfo(rack)
242+
if err != nil {
243+
return err
244+
}
245+
246+
// Apply MFA auth if provided
247+
requestBearer := bearer
248+
if mfaAuth != "" {
249+
requestBearer = bearer + "." + mfaAuth
250+
}
251+
252+
statusCode, responseBody, err := doGatewayRequest(gatewayURL, requestBearer, method, path, body)
253+
if err != nil {
254+
return err
255+
}
256+
257+
if statusCode >= 400 {
258+
return fmt.Errorf("gateway request failed (%d): %s", statusCode, strings.TrimSpace(string(responseBody)))
259+
}
260+
261+
if out != nil {
262+
if err := json.Unmarshal(responseBody, out); err != nil {
263+
return fmt.Errorf("failed to decode response: %w", err)
264+
}
265+
}
266+
267+
return nil
268+
}
269+
236270
func maybeRetryWithMFA(
237271
cmd *cobra.Command,
238272
statusCode int,

0 commit comments

Comments
 (0)