Skip to content

Commit 372f463

Browse files
feat: extend commands coverage & add request wait for approval (#2)
1 parent 2daf60a commit 372f463

8 files changed

Lines changed: 291 additions & 13 deletions

File tree

Formula/team-cli.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ class TeamCli < Formula
44
desc "CLI for AWS TEAM (Temporary Elevated Access Management)"
55
homepage "https://github.com/DocPlanner/team-cli"
66
url "https://github.com/DocPlanner/team-cli.git", branch: "main"
7-
version "0.1.0"
7+
version "0.2.0"
88

99

1010
depends_on "python@3.11"

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,21 +106,31 @@ team request --account my-account --role ReadOnly --duration 2 --justification "
106106
| `--justification`, `-j` | Business justification (required) |
107107
| `--ticket`, `-t` | Ticket number |
108108
| `--start`, `-s` | Start time in ISO format (default: now) |
109+
| `--wait`, `-w` | Wait for request to reach a terminal state (for scripting) |
110+
| `--wait-timeout` | Max seconds to wait (default: 600) |
109111

110112
In interactive mode, you can select multiple accounts and reuse the same justification across them.
111113

114+
The `--wait` flag blocks until the request is approved, rejected, or otherwise resolved. Exit code 0 for approved/in-progress, 1 for rejected/error, 2 for timeout. When piped, outputs final request state as JSON.
115+
112116
### Check request status
113117

114118
```bash
115119
team requests # list all your requests
116120
team status <request-id> # detailed view of a single request
121+
team pending # list requests awaiting your approval
117122
```
118123

119-
### Approve requests
124+
### Approve, reject, revoke, cancel
120125

121126
```bash
122-
team approve <request-id>
123-
team approve <request-id> --comment "Looks good"
127+
team approve <request-id> # approve pending request
128+
team approve <request-id> --comment "LGTM" # with comment
129+
team reject <request-id> # reject pending request
130+
team reject <request-id> -c "Not justified" # with reason
131+
team revoke <request-id> # revoke active session
132+
team revoke <request-id> -c "Security event" # with reason
133+
team cancel <request-id> # cancel own pending request
124134
```
125135

126136
### Audit elevation requests

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "team-cli"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "CLI for AWS TEAM (Temporary Elevated Access Management)"
99
requires-python = ">=3.11"
1010
dependencies = [

team_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
"""CLI for AWS TEAM (Temporary Elevated Access Management)."""
2-
__version__ = "0.1.0"
2+
__version__ = "0.2.0"

team_cli/api.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,26 @@ def get_requests_by_email(email: str, tokens: dict, status: dict | None = None)
140140
return items
141141

142142

143+
def get_requests_by_approver(approver_id: str, tokens: dict, status: dict | None = None) -> list:
144+
"""Fetch requests assigned to an approver, with optional status filter. Paginates automatically."""
145+
from team_cli.queries import REQUEST_BY_APPROVER_AND_STATUS
146+
items = []
147+
next_token = None
148+
while True:
149+
variables = {"approverId": approver_id, "limit": 50}
150+
if status:
151+
variables["status"] = status
152+
if next_token:
153+
variables["nextToken"] = next_token
154+
data = execute(REQUEST_BY_APPROVER_AND_STATUS, variables, tokens)
155+
result = data.get("requestByApproverAndStatus", {})
156+
items.extend(result.get("items", []))
157+
next_token = result.get("nextToken")
158+
if not next_token:
159+
break
160+
return items
161+
162+
143163
def get_request(request_id: str, tokens: dict) -> dict:
144164
"""Fetch a single request by ID."""
145165
from team_cli.queries import GET_REQUESTS

team_cli/cli.py

Lines changed: 195 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
from team_cli.auth import require_auth, get_user_info, login as auth_login, get_valid_tokens, clear_tokens
88
from 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
)
1313
from team_cli.config import CONFIG_FILE, load_config, save_config, get_config
1414
from 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+
197231
def _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

240293
def _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"\nCreating {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

305368
def _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

338403
def 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+
393557
def 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,

team_cli/interactive.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,9 @@ def format_request_table(requests_list: list[dict]) -> str:
158158
"approved": "✅",
159159
"rejected": "❌",
160160
"revoked": "🔄",
161+
"cancelled": "🚫",
162+
"in progress": "⏩",
163+
"scheduled": "📅",
161164
"ended": "⏹",
162165
"expired": "💤",
163166
}
@@ -184,7 +187,8 @@ def format_request_detail(r: dict) -> str:
184187
"""Format a single request as detailed view."""
185188
status_icon = {
186189
"pending": "⏳", "approved": "✅", "rejected": "❌",
187-
"revoked": "🔄", "ended": "⏹", "expired": "💤",
190+
"revoked": "🔄", "cancelled": "🚫", "in progress": "⏩",
191+
"scheduled": "📅", "ended": "⏹", "expired": "💤",
188192
}
189193
status = r.get("status", "unknown")
190194
icon = status_icon.get(status.lower(), "?")
@@ -206,6 +210,10 @@ def format_request_detail(r: dict) -> str:
206210
lines.append(f"Approver: {r.get('approver', '')}")
207211
if r.get("comment"):
208212
lines.append(f"Comment: {r.get('comment', '')}")
213+
if r.get("revoker"):
214+
lines.append(f"Revoker: {r.get('revoker', '')}")
215+
if r.get("revokeComment"):
216+
lines.append(f"Revoke reason: {r.get('revokeComment', '')}")
209217

210218
lines.append(f"Created: {r.get('createdAt', '')}")
211219
lines.append(f"Updated: {r.get('updatedAt', '')}")

0 commit comments

Comments
 (0)