Skip to content

Commit 0aa84d7

Browse files
authored
feat: add remaining time and group names to grant status (#36)
Track elevation timestamps locally in ~/.grant/cache/session_timestamps.json so grant status can show remaining session time instead of total duration. Resolve group names from the groups eligibility API so group sessions display human-readable names instead of UUIDs. Both features degrade gracefully: sessions elevated outside grant show total duration, and unresolved groups fall back to UUID display. JSON output adds remainingSeconds and groupName as additive omitempty fields.
1 parent ff80181 commit 0aa84d7

16 files changed

+994
-47
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ All notable changes to this project will be documented in this file.
99
- TTY detection with fail-fast: all interactive prompts now return a descriptive error instead of hanging when stdin is not a terminal (pipes, CI, LLM agents)
1010
- `--output json` / `-o json` global flag for machine-readable output on all commands (`grant`, `env`, `status`, `revoke`, `favorites list`)
1111
- `grant list` command to discover eligible cloud targets and Entra ID groups without triggering elevation; supports `--provider`, `--groups`, `--refresh`, and `--output json`
12+
- `grant status` now shows remaining session time instead of total duration for sessions elevated via `grant` or `grant env`; sessions elevated outside the CLI continue to show total duration
13+
- `grant status` now resolves group names from the groups eligibility API — group sessions display `Group: CloudAdmins in Contoso` instead of `Group: d554b344-uuid in 29cb7961-uuid`
14+
- JSON output for `grant status` includes new additive fields: `remainingSeconds` (omitted when unknown) and `groupName` (omitted when unresolved)
15+
- Session elevation timestamps tracked locally in `~/.grant/cache/session_timestamps.json` with automatic cleanup of stale entries
1216

1317
## [0.5.1] - 2026-02-21
1418

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,11 @@ Custom `SCAAccessService` follows SDK conventions:
8585
- `--refresh` flag on `grant` and `grant env` bypasses cache reads but still writes fresh data
8686
- `internal/cache/cache.go` — generic `Store` with `Get[T]`/`Set[T]`, injectable clock for testing
8787
- `internal/cache/cached_eligibility.go``CachedEligibilityLister` decorator implementing `eligibilityLister` + `groupsEligibilityLister`
88+
- `internal/cache/session_tracker.go``RecordSession`, `SessionTimestamps`, `CleanupSessions` for tracking elevation timestamps in `session_timestamps.json` (25h TTL, auto-cleanup of inactive sessions)
8889
- `buildCachedLister()` in `cmd/root.go` — shared factory used by all commands (root, env, status, revoke, favorites add)
8990
- Commands without `--refresh` (status, revoke, favorites add) always pass `refresh: false` — they use eligibility for display only
9091
- Cache failures (read/write) silently fall through to the live API
92+
- `cmd/session_tracking.go``recordSessionTimestamp` var (injectable for tests), called after elevation in root and env commands
9193

9294
## Verbose / Logging
9395
- `--verbose` / `-v` global flag wired via `PersistentPreRunE` in `cmd/root.go`

cmd/env.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ func runEnvWithDeps(
9292
return err
9393
}
9494

95+
// Record session timestamp for remaining-time tracking (best-effort)
96+
recordSessionTimestamp(res.result.SessionID)
97+
9598
if res.result.AccessCredentials == nil {
9699
return errors.New("no credentials returned; grant env is only supported for AWS elevations")
97100
}

cmd/env_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,54 @@ func TestEnvCommand_NotAuthenticated(t *testing.T) {
155155
}
156156
}
157157

158+
func TestEnvCommand_RecordsSessionTimestamp(t *testing.T) {
159+
originalRecorder := recordSessionTimestamp
160+
defer func() { recordSessionTimestamp = originalRecorder }()
161+
162+
var recorded string
163+
recordSessionTimestamp = func(sessionID string) { recorded = sessionID }
164+
165+
credsJSON := `{"aws_access_key":"ASIAXXX","aws_secret_access_key":"secret","aws_session_token":"tok"}`
166+
167+
authLoader := &mockAuthLoader{
168+
token: &authmodels.IdsecToken{Token: "test-jwt"},
169+
}
170+
eligLister := &mockEligibilityLister{
171+
response: &models.EligibilityResponse{
172+
Response: []models.EligibleTarget{{
173+
OrganizationID: "o-1", WorkspaceID: "acct-1", WorkspaceName: "AWS Mgmt",
174+
WorkspaceType: models.WorkspaceTypeAccount,
175+
RoleInfo: models.RoleInfo{ID: "role-1", Name: "Admin"},
176+
}}, Total: 1,
177+
},
178+
}
179+
elevSvc := &mockElevateService{
180+
response: &models.ElevateResponse{Response: models.ElevateAccessResult{
181+
CSP: models.CSPAWS, OrganizationID: "o-1",
182+
Results: []models.ElevateTargetResult{{
183+
WorkspaceID: "acct-1", RoleID: "Admin", SessionID: "env-sess-1",
184+
AccessCredentials: &credsJSON,
185+
}},
186+
}},
187+
}
188+
selector := &mockTargetSelector{
189+
target: &models.EligibleTarget{
190+
OrganizationID: "o-1", WorkspaceID: "acct-1", WorkspaceName: "AWS Mgmt",
191+
WorkspaceType: models.WorkspaceTypeAccount,
192+
RoleInfo: models.RoleInfo{ID: "role-1", Name: "Admin"},
193+
},
194+
}
195+
196+
cmd := NewEnvCommandWithDeps(nil, authLoader, eligLister, elevSvc, selector, config.DefaultConfig())
197+
_, err := executeCommand(cmd, "--provider", "aws")
198+
if err != nil {
199+
t.Fatalf("unexpected error: %v", err)
200+
}
201+
if recorded != "env-sess-1" {
202+
t.Errorf("recorded session = %q, want env-sess-1", recorded)
203+
}
204+
}
205+
158206
func TestNewEnvCommand_RefreshFlagRegistered(t *testing.T) {
159207
cmd := newEnvCommand(nil)
160208
if cmd.Flags().Lookup("refresh") == nil {

cmd/helpers.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import (
55
"fmt"
66
"strings"
77
"sync"
8+
"time"
89

910
scamodels "github.com/aaearon/grant-cli/internal/sca/models"
1011
)
1112

1213
// statusData holds the results of concurrent sessions + eligibility fetches.
1314
type statusData struct {
14-
sessions *scamodels.SessionsResponse
15-
nameMap map[string]string
15+
sessions *scamodels.SessionsResponse
16+
nameMap map[string]string
17+
groupNameMap map[string]string // groupID -> groupName
18+
remainingMap map[string]time.Duration // sessionID -> remaining time
1619
}
1720

1821
// fetchStatusData fires sessions and all-CSP eligibility calls concurrently,
@@ -187,6 +190,26 @@ func buildWorkspaceNameMap(ctx context.Context, eligLister eligibilityLister, se
187190
return nameMap
188191
}
189192

193+
// buildGroupNameMap fetches groups eligibility and builds a groupID -> groupName map.
194+
// Errors are logged and an empty map is returned (graceful degradation).
195+
func buildGroupNameMap(ctx context.Context, gl groupsEligibilityLister) map[string]string {
196+
nameMap := make(map[string]string)
197+
if gl == nil {
198+
return nameMap
199+
}
200+
201+
resp, err := gl.ListGroupsEligibility(ctx, scamodels.CSPAzure)
202+
if err != nil {
203+
log.Info("failed to fetch group names: %v", err)
204+
return nameMap
205+
}
206+
207+
for _, g := range resp.Response {
208+
nameMap[g.GroupID] = g.GroupName
209+
}
210+
return nameMap
211+
}
212+
190213
// findMatchingGroup finds a group by name (case-insensitive).
191214
// If directoryID is non-empty, only matches groups in that directory.
192215
func findMatchingGroup(groups []scamodels.GroupsEligibleTarget, name, directoryID string) *scamodels.GroupsEligibleTarget {

cmd/helpers_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,51 @@ func TestBuildWorkspaceNameMap(t *testing.T) {
438438
}
439439
}
440440

441+
func TestBuildGroupNameMap_Success(t *testing.T) {
442+
ctx := t.Context()
443+
gl := &mockGroupsEligibilityLister{
444+
response: &scamodels.GroupsEligibilityResponse{
445+
Response: []scamodels.GroupsEligibleTarget{
446+
{GroupID: "grp-1", GroupName: "CloudAdmins", DirectoryID: "dir-1"},
447+
{GroupID: "grp-2", GroupName: "DevOps", DirectoryID: "dir-1"},
448+
},
449+
Total: 2,
450+
},
451+
}
452+
453+
m := buildGroupNameMap(ctx, gl)
454+
if len(m) != 2 {
455+
t.Fatalf("expected 2 entries, got %d", len(m))
456+
}
457+
if m["grp-1"] != "CloudAdmins" {
458+
t.Errorf("grp-1 = %q, want CloudAdmins", m["grp-1"])
459+
}
460+
if m["grp-2"] != "DevOps" {
461+
t.Errorf("grp-2 = %q, want DevOps", m["grp-2"])
462+
}
463+
}
464+
465+
func TestBuildGroupNameMap_Error(t *testing.T) {
466+
ctx := t.Context()
467+
gl := &mockGroupsEligibilityLister{
468+
listErr: errors.New("groups API unavailable"),
469+
}
470+
471+
m := buildGroupNameMap(ctx, gl)
472+
if len(m) != 0 {
473+
t.Errorf("expected empty map on error, got %v", m)
474+
}
475+
}
476+
477+
func TestBuildGroupNameMap_NilLister(t *testing.T) {
478+
ctx := t.Context()
479+
480+
m := buildGroupNameMap(ctx, nil)
481+
if len(m) != 0 {
482+
t.Errorf("expected empty map for nil lister, got %v", m)
483+
}
484+
}
485+
441486
func TestBuildWorkspaceNameMap_VerboseWarning(t *testing.T) {
442487
spy := &spyLogger{}
443488
oldLog := log

cmd/output_types.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,16 @@ type groupElevationJSON struct {
2929

3030
// sessionOutput is the JSON representation of an active session.
3131
type sessionOutput struct {
32-
SessionID string `json:"sessionId"`
33-
Provider string `json:"provider"`
34-
WorkspaceID string `json:"workspaceId"`
35-
WorkspaceName string `json:"workspaceName,omitempty"`
36-
RoleID string `json:"roleId,omitempty"`
37-
Duration int `json:"duration"`
38-
Type string `json:"type"`
39-
GroupID string `json:"groupId,omitempty"`
32+
SessionID string `json:"sessionId"`
33+
Provider string `json:"provider"`
34+
WorkspaceID string `json:"workspaceId"`
35+
WorkspaceName string `json:"workspaceName,omitempty"`
36+
RoleID string `json:"roleId,omitempty"`
37+
Duration int `json:"duration"`
38+
RemainingSeconds *int `json:"remainingSeconds,omitempty"`
39+
Type string `json:"type"`
40+
GroupID string `json:"groupId,omitempty"`
41+
GroupName string `json:"groupName,omitempty"`
4042
}
4143

4244
// statusOutput is the JSON representation of grant status.

cmd/root.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,13 @@ func runElevateWithDeps(
781781
return err
782782
}
783783

784+
// Record session timestamp for remaining-time tracking (best-effort)
785+
if groupRes != nil {
786+
recordSessionTimestamp(groupRes.result.SessionID)
787+
} else if cloudRes != nil {
788+
recordSessionTimestamp(cloudRes.result.SessionID)
789+
}
790+
784791
if isJSONOutput() {
785792
return writeElevationJSON(cmd, cloudRes, groupRes)
786793
}

cmd/root_elevate_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,6 +1464,129 @@ func TestRootElevate_GroupFavoriteDirectoryID(t *testing.T) {
14641464
}
14651465
}
14661466

1467+
func TestRootElevate_RecordsSessionTimestamp(t *testing.T) {
1468+
now := time.Now()
1469+
expiresIn := commonmodels.IdsecRFC3339Time(now.Add(1 * time.Hour))
1470+
1471+
// Save and restore the real recorder
1472+
originalRecorder := recordSessionTimestamp
1473+
defer func() { recordSessionTimestamp = originalRecorder }()
1474+
1475+
t.Run("cloud elevation records timestamp", func(t *testing.T) {
1476+
var recorded string
1477+
recordSessionTimestamp = func(sessionID string) { recorded = sessionID }
1478+
1479+
authLoader := &mockAuthLoader{
1480+
token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn},
1481+
}
1482+
eligLister := &mockEligibilityLister{
1483+
response: &models.EligibilityResponse{
1484+
Response: []models.EligibleTarget{{
1485+
OrganizationID: "org-1", WorkspaceID: "sub-1", WorkspaceName: "Prod",
1486+
WorkspaceType: models.WorkspaceTypeSubscription,
1487+
RoleInfo: models.RoleInfo{ID: "role-1", Name: "Contributor"},
1488+
}}, Total: 1,
1489+
},
1490+
}
1491+
elevSvc := &mockElevateService{
1492+
response: &models.ElevateResponse{Response: models.ElevateAccessResult{
1493+
CSP: models.CSPAzure, OrganizationID: "org-1",
1494+
Results: []models.ElevateTargetResult{{WorkspaceID: "sub-1", RoleID: "role-1", SessionID: "cloud-sess-1"}},
1495+
}},
1496+
}
1497+
selector := &mockUnifiedSelector{
1498+
item: &selectionItem{kind: selectionCloud, cloud: &models.EligibleTarget{
1499+
OrganizationID: "org-1", WorkspaceID: "sub-1", WorkspaceName: "Prod",
1500+
WorkspaceType: models.WorkspaceTypeSubscription,
1501+
RoleInfo: models.RoleInfo{ID: "role-1", Name: "Contributor"},
1502+
}},
1503+
}
1504+
1505+
cmd := NewRootCommandWithDeps(nil, authLoader, eligLister, elevSvc, selector, &mockGroupsEligibilityLister{response: &models.GroupsEligibilityResponse{}}, nil, config.DefaultConfig())
1506+
_, err := executeCommand(cmd, "--provider", "azure")
1507+
if err != nil {
1508+
t.Fatalf("unexpected error: %v", err)
1509+
}
1510+
if recorded != "cloud-sess-1" {
1511+
t.Errorf("recorded session = %q, want cloud-sess-1", recorded)
1512+
}
1513+
})
1514+
1515+
t.Run("group elevation records timestamp", func(t *testing.T) {
1516+
var recorded string
1517+
recordSessionTimestamp = func(sessionID string) { recorded = sessionID }
1518+
1519+
authLoader := &mockAuthLoader{
1520+
token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn},
1521+
}
1522+
cloudElig := &mockEligibilityLister{
1523+
response: &models.EligibilityResponse{Response: []models.EligibleTarget{}},
1524+
}
1525+
groupsElig := &mockGroupsEligibilityLister{
1526+
response: &models.GroupsEligibilityResponse{
1527+
Response: []models.GroupsEligibleTarget{
1528+
{DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"},
1529+
},
1530+
Total: 1,
1531+
},
1532+
}
1533+
groupsElev := &mockGroupsElevator{
1534+
response: &models.GroupsElevateResponse{
1535+
DirectoryID: "dir1", CSP: models.CSPAzure,
1536+
Results: []models.GroupsElevateTargetResult{{GroupID: "grp1", SessionID: "grp-sess-1"}},
1537+
},
1538+
}
1539+
1540+
cmd := NewRootCommandWithDeps(nil, authLoader, cloudElig, nil, nil, groupsElig, groupsElev, config.DefaultConfig())
1541+
_, err := executeCommand(cmd, "--group", "Engineering")
1542+
if err != nil {
1543+
t.Fatalf("unexpected error: %v", err)
1544+
}
1545+
if recorded != "grp-sess-1" {
1546+
t.Errorf("recorded session = %q, want grp-sess-1", recorded)
1547+
}
1548+
})
1549+
1550+
t.Run("recording failure does not break elevation", func(t *testing.T) {
1551+
recordSessionTimestamp = func(sessionID string) {
1552+
// Simulate recording failure by panicking — if it breaks, the test fails
1553+
// Actually we just verify the function runs without affecting the result
1554+
}
1555+
1556+
authLoader := &mockAuthLoader{
1557+
token: &authmodels.IdsecToken{Token: "jwt", Username: "user@example.com", ExpiresIn: expiresIn},
1558+
}
1559+
eligLister := &mockEligibilityLister{
1560+
response: &models.EligibilityResponse{
1561+
Response: []models.EligibleTarget{{
1562+
OrganizationID: "org-1", WorkspaceID: "sub-1", WorkspaceName: "Prod",
1563+
WorkspaceType: models.WorkspaceTypeSubscription,
1564+
RoleInfo: models.RoleInfo{ID: "role-1", Name: "Contributor"},
1565+
}}, Total: 1,
1566+
},
1567+
}
1568+
elevSvc := &mockElevateService{
1569+
response: &models.ElevateResponse{Response: models.ElevateAccessResult{
1570+
CSP: models.CSPAzure, OrganizationID: "org-1",
1571+
Results: []models.ElevateTargetResult{{WorkspaceID: "sub-1", RoleID: "role-1", SessionID: "sess-ok"}},
1572+
}},
1573+
}
1574+
selector := &mockUnifiedSelector{
1575+
item: &selectionItem{kind: selectionCloud, cloud: &models.EligibleTarget{
1576+
OrganizationID: "org-1", WorkspaceID: "sub-1", WorkspaceName: "Prod",
1577+
WorkspaceType: models.WorkspaceTypeSubscription,
1578+
RoleInfo: models.RoleInfo{ID: "role-1", Name: "Contributor"},
1579+
}},
1580+
}
1581+
1582+
cmd := NewRootCommandWithDeps(nil, authLoader, eligLister, elevSvc, selector, &mockGroupsEligibilityLister{response: &models.GroupsEligibilityResponse{}}, nil, config.DefaultConfig())
1583+
_, err := executeCommand(cmd, "--provider", "azure")
1584+
if err != nil {
1585+
t.Errorf("elevation should succeed even if recording fails: %v", err)
1586+
}
1587+
})
1588+
}
1589+
14671590
func TestRootElevate_MutualExclusivity(t *testing.T) {
14681591
tests := []struct {
14691592
name string

cmd/session_tracking.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package cmd
2+
3+
import (
4+
"time"
5+
6+
"github.com/aaearon/grant-cli/internal/cache"
7+
)
8+
9+
// sessionTimestampRecorder records elevation timestamps. Package-level var for test injection.
10+
var recordSessionTimestamp = func(sessionID string) {
11+
dir, err := cache.CacheDir()
12+
if err != nil {
13+
log.Info("failed to record session timestamp: %v", err)
14+
return
15+
}
16+
store := cache.NewStore(dir, 25*time.Hour)
17+
if err := cache.RecordSession(store, sessionID, time.Now()); err != nil {
18+
log.Info("failed to record session timestamp: %v", err)
19+
}
20+
}

0 commit comments

Comments
 (0)