66
77from team_cli .auth import require_auth , get_user_info , login as auth_login , get_valid_tokens , clear_tokens
88from team_cli .api import (
9- get_user_policy , get_requests_by_email , get_request ,
10- create_request , update_request , validate_request , get_settings ,
11- AuthExpiredError ,
9+ get_user_policy , get_requests_by_email , get_requests_by_approver ,
10+ get_request , create_request , update_request , validate_request ,
11+ get_settings , AuthExpiredError ,
1212)
1313from team_cli .config import CONFIG_FILE , load_config , save_config , get_config
1414from team_cli .interactive import (
@@ -194,6 +194,40 @@ def _get_permissions_for_account(account_id: str, policy: list[dict]) -> list[di
194194 return list (perms .values ())
195195
196196
197+ def _poll_request (request_id : str , tokens : dict , timeout : int = 600 , interval : int = 5 ) -> dict :
198+ """Poll a request until it reaches a terminal state or timeout."""
199+ import time as _time
200+
201+ terminal = {"approved" , "rejected" , "cancelled" , "expired" , "error" ,
202+ "in progress" , "ended" , "revoked" }
203+ status = "pending"
204+ elapsed = 0
205+
206+ while elapsed < timeout :
207+ req = get_request (request_id , tokens )
208+ status = (req .get ("status" ) or "" ).lower ()
209+ if status in terminal :
210+ print (file = sys .stderr ) # clear the progress line
211+ return req
212+ remaining = timeout - elapsed
213+ print (f"\r ⏳ Waiting for approval... { remaining } s remaining " ,
214+ end = "" , file = sys .stderr , flush = True )
215+ _time .sleep (interval )
216+ elapsed += interval
217+ # Refresh tokens in case they expire during long waits
218+ try :
219+ refreshed = get_valid_tokens ()
220+ if refreshed :
221+ tokens .update (refreshed )
222+ except Exception :
223+ pass
224+
225+ print (file = sys .stderr )
226+ print (f"Timed out after { timeout } s. Request { request_id } still { status } ." ,
227+ file = sys .stderr )
228+ sys .exit (2 )
229+
230+
197231def _request_flag_mode (args , tokens , user , policy , all_accounts , max_duration ):
198232 """Handle request creation via CLI flags."""
199233 account = _find_account (args .account , all_accounts )
@@ -234,7 +268,26 @@ def _request_flag_mode(args, tokens, user, policy, all_accounts, max_duration):
234268 ticket = args .ticket or ""
235269 start_time = args .start or datetime .now (timezone .utc ).isoformat ()
236270
237- _submit_request (tokens , user , account , role , duration , start_time , justification , ticket )
271+ result = _submit_request (tokens , user , account , role , duration , start_time , justification , ticket )
272+
273+ if args .wait and result and result .get ("id" ):
274+ _wait_for_request (result ["id" ], tokens , args .wait_timeout )
275+
276+
277+ def _wait_for_request (request_id : str , tokens : dict , timeout : int ):
278+ """Wait for a request and output final state."""
279+ final = _poll_request (request_id , tokens , timeout = timeout )
280+ status = (final .get ("status" ) or "" ).lower ()
281+
282+ if not sys .stdout .isatty ():
283+ import json as json_mod
284+ print (json_mod .dumps (final , indent = 2 ))
285+ else :
286+ print (format_request_detail (final ))
287+
288+ success = {"approved" , "in progress" }
289+ if status not in success :
290+ sys .exit (1 )
238291
239292
240293def _request_interactive_mode (tokens , user , policy , all_accounts , max_duration , args ):
@@ -274,6 +327,7 @@ def _request_interactive_mode(tokens, user, policy, all_accounts, max_duration,
274327
275328 print (f"\n Creating { len (selected_accounts )} request(s)..." )
276329
330+ results = []
277331 for i , acct in enumerate (selected_accounts ):
278332 if prev_justification is None :
279333 # First account or user wants different justification
@@ -299,7 +353,16 @@ def _request_interactive_mode(tokens, user, policy, all_accounts, max_duration,
299353 prev_justification = justification
300354 prev_ticket = ticket
301355
302- _submit_request (tokens , user , acct , role , duration , start_time , justification , ticket )
356+ result = _submit_request (tokens , user , acct , role , duration , start_time , justification , ticket )
357+ if result :
358+ results .append (result )
359+
360+ if args .wait and results :
361+ if len (results ) == 1 :
362+ _wait_for_request (results [0 ]["id" ], tokens , args .wait_timeout )
363+ else :
364+ print ("\n --wait is only supported for single-account requests. "
365+ "Use `team status <id>` to check individual requests." , file = sys .stderr )
303366
304367
305368def _submit_request (tokens , user , account , role , duration , start_time , justification , ticket ):
@@ -311,7 +374,7 @@ def _submit_request(tokens, user, account, role, duration, start_time, justifica
311374 )
312375 if not validation .get ("valid" ):
313376 print (f" ✗ { account ['name' ]} → denied: { validation .get ('reason' , 'unknown' )} " )
314- return
377+ return None
315378 except Exception as e :
316379 print (f" ⚠ { account ['name' ]} → validation failed: { e } " )
317380 # Continue anyway — server-side validation will catch issues
@@ -331,8 +394,10 @@ def _submit_request(tokens, user, account, role, duration, start_time, justifica
331394 req_id = result .get ("id" , "unknown" )
332395 status = result .get ("status" , "pending" )
333396 print (f" ✓ { account ['name' ]} → { status } (id: { req_id } )" )
397+ return result
334398 except Exception as e :
335399 print (f" ✗ { account ['name' ]} → failed: { e } " )
400+ return None
336401
337402
338403def cmd_requests (args ):
@@ -390,6 +455,105 @@ def cmd_approve(args):
390455 print (f"✓ Request { args .request_id } approved" )
391456
392457
458+ def cmd_reject (args ):
459+ """team reject — reject a pending request."""
460+ tokens = _ensure_tokens ()
461+ user = get_user_info (tokens )
462+
463+ with with_spinner ("Fetching request..." ):
464+ req = get_request (args .request_id , tokens )
465+
466+ if not req :
467+ print (f"Request not found: { args .request_id } " , file = sys .stderr )
468+ sys .exit (1 )
469+
470+ if req .get ("status" , "" ).lower () != "pending" :
471+ print (f"Request is not pending (status: { req .get ('status' )} )" , file = sys .stderr )
472+ sys .exit (1 )
473+
474+ print (f"Rejecting request from { req .get ('email' , '' )} for { req .get ('accountName' , '' )} / { req .get ('role' , '' )} " )
475+
476+ update_request ({
477+ "id" : args .request_id ,
478+ "status" : "rejected" ,
479+ "approver" : user ["email" ],
480+ "approverId" : user ["user_id" ],
481+ "comment" : args .comment or "" ,
482+ }, tokens )
483+
484+ print (f"✓ Request { args .request_id } rejected" )
485+
486+
487+ def cmd_revoke (args ):
488+ """team revoke — revoke an active/approved request."""
489+ tokens = _ensure_tokens ()
490+
491+ with with_spinner ("Fetching request..." ):
492+ req = get_request (args .request_id , tokens )
493+
494+ if not req :
495+ print (f"Request not found: { args .request_id } " , file = sys .stderr )
496+ sys .exit (1 )
497+
498+ revocable = {"approved" , "scheduled" , "in progress" }
499+ current = req .get ("status" , "" ).lower ()
500+ if current not in revocable :
501+ print (f"Request cannot be revoked (status: { req .get ('status' )} ). "
502+ f"Revocable statuses: { ', ' .join (sorted (revocable ))} " , file = sys .stderr )
503+ sys .exit (1 )
504+
505+ print (f"Revoking request from { req .get ('email' , '' )} for { req .get ('accountName' , '' )} / { req .get ('role' , '' )} " )
506+
507+ update_request ({
508+ "id" : args .request_id ,
509+ "status" : "revoked" ,
510+ "revokeComment" : args .comment or "" ,
511+ }, tokens )
512+
513+ print (f"✓ Request { args .request_id } revoked" )
514+
515+
516+ def cmd_cancel (args ):
517+ """team cancel — cancel own pending request."""
518+ tokens = _ensure_tokens ()
519+ user = get_user_info (tokens )
520+
521+ with with_spinner ("Fetching request..." ):
522+ req = get_request (args .request_id , tokens )
523+
524+ if not req :
525+ print (f"Request not found: { args .request_id } " , file = sys .stderr )
526+ sys .exit (1 )
527+
528+ if req .get ("status" , "" ).lower () != "pending" :
529+ print (f"Request is not pending (status: { req .get ('status' )} )" , file = sys .stderr )
530+ sys .exit (1 )
531+
532+ if req .get ("email" , "" ).lower () != user ["email" ].lower ():
533+ print (f"Cannot cancel another user's request (owner: { req .get ('email' )} )" , file = sys .stderr )
534+ sys .exit (1 )
535+
536+ print (f"Cancelling request for { req .get ('accountName' , '' )} / { req .get ('role' , '' )} " )
537+
538+ update_request ({
539+ "id" : args .request_id ,
540+ "status" : "cancelled" ,
541+ }, tokens )
542+
543+ print (f"✓ Request { args .request_id } cancelled" )
544+
545+
546+ def cmd_pending (args ):
547+ """team pending — list requests awaiting my approval."""
548+ tokens = _ensure_tokens ()
549+ user = get_user_info (tokens )
550+
551+ with with_spinner ("Fetching pending requests..." ):
552+ reqs = get_requests_by_approver (user ["user_id" ], tokens , status = {"eq" : "pending" })
553+
554+ print (format_request_table (reqs ))
555+
556+
393557def cmd_sync (args ):
394558 """team sync — add missing accounts to ~/.aws/config."""
395559 config = get_config ()
@@ -612,6 +776,10 @@ def build_parser() -> argparse.ArgumentParser:
612776 req_parser .add_argument ("--justification" , "-j" , help = "Business justification" )
613777 req_parser .add_argument ("--ticket" , "-t" , help = "Ticket number" )
614778 req_parser .add_argument ("--start" , "-s" , help = "Start time (ISO format, default: now)" )
779+ req_parser .add_argument ("--wait" , "-w" , action = "store_true" ,
780+ help = "Wait for request to reach a terminal state" )
781+ req_parser .add_argument ("--wait-timeout" , type = int , default = 600 ,
782+ help = "Max seconds to wait (default: 600)" )
615783
616784 # requests
617785 sub .add_parser ("requests" , help = "List my requests" )
@@ -625,6 +793,23 @@ def build_parser() -> argparse.ArgumentParser:
625793 approve_parser .add_argument ("request_id" , help = "Request ID" )
626794 approve_parser .add_argument ("--comment" , "-c" , help = "Approval comment" )
627795
796+ # reject
797+ reject_parser = sub .add_parser ("reject" , help = "Reject a pending request" )
798+ reject_parser .add_argument ("request_id" , help = "Request ID" )
799+ reject_parser .add_argument ("--comment" , "-c" , help = "Rejection reason" )
800+
801+ # revoke
802+ revoke_parser = sub .add_parser ("revoke" , help = "Revoke an active/approved request" )
803+ revoke_parser .add_argument ("request_id" , help = "Request ID" )
804+ revoke_parser .add_argument ("--comment" , "-c" , help = "Revoke reason" )
805+
806+ # cancel
807+ cancel_parser = sub .add_parser ("cancel" , help = "Cancel own pending request" )
808+ cancel_parser .add_argument ("request_id" , help = "Request ID" )
809+
810+ # pending
811+ sub .add_parser ("pending" , help = "List requests awaiting my approval" )
812+
628813 # audit
629814 audit_parser = sub .add_parser ("audit" , help = "Audit elevation requests with CloudTrail events" )
630815 audit_parser .add_argument ("--actor" , help = "Filter by requester email" )
@@ -657,6 +842,10 @@ def build_parser() -> argparse.ArgumentParser:
657842 "requests" : cmd_requests ,
658843 "status" : cmd_status ,
659844 "approve" : cmd_approve ,
845+ "reject" : cmd_reject ,
846+ "revoke" : cmd_revoke ,
847+ "cancel" : cmd_cancel ,
848+ "pending" : cmd_pending ,
660849 "audit" : cmd_audit ,
661850 "sync" : cmd_sync ,
662851 "configure" : cmd_configure ,
0 commit comments