From cd2207b35881c819202998bd5bb0ee4b8d4157c9 Mon Sep 17 00:00:00 2001 From: YoungJinJung Date: Sat, 28 Mar 2026 17:28:42 +0900 Subject: [PATCH] feat: add IAM access key browser and rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add IAM Access Key list view with status, age, and last-used date - Add Access Key rotation workflow: create → verify/apply → deactivate → delete - Auto-apply new key to ~/.aws/credentials for credential-based auth - Add clipboard support for copying new key as export commands - Highlight aged keys (>90 days) with warning indicator - Add confirmation prompt requiring key ID input before rotation - Update README with IAM features and key bindings --- CLAUDE.md | 9 + README.md | 11 +- go.mod | 7 +- go.sum | 8 + internal/app/app.go | 106 +++++- internal/app/app_test.go | 188 +++++++++++ internal/app/messages.go | 24 ++ internal/app/screen_iam.go | 490 ++++++++++++++++++++++++++++ internal/auth/auth.go | 23 +- internal/auth/credentials.go | 122 +++++++ internal/auth/credentials_test.go | 78 +++++ internal/clipboard/clipboard.go | 28 ++ internal/config/config.go | 23 +- internal/config/config_test.go | 37 +++ internal/domain/catalog.go | 13 + internal/domain/catalog_test.go | 31 ++ internal/domain/model.go | 13 +- internal/services/aws/iam.go | 132 ++++++++ internal/services/aws/iam_model.go | 52 +++ internal/services/aws/iam_test.go | 367 +++++++++++++++++++++ internal/services/aws/repository.go | 26 +- 21 files changed, 1745 insertions(+), 43 deletions(-) create mode 100644 internal/app/screen_iam.go create mode 100644 internal/auth/credentials.go create mode 100644 internal/auth/credentials_test.go create mode 100644 internal/clipboard/clipboard.go create mode 100644 internal/services/aws/iam.go create mode 100644 internal/services/aws/iam_model.go create mode 100644 internal/services/aws/iam_test.go diff --git a/CLAUDE.md b/CLAUDE.md index da24d5e..0768352 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,3 +14,12 @@ - Use lipgloss for styled TUI output — column-aligned tables with dimmed labels - Tests use mock client interfaces (see `rds_test.go` pattern) - Scroll windowing: `visibleLines := max(m.height-N, 5)` + +## README Maintenance + +- **피쳐를 추가, 수정, 삭제할 때 반드시 `README.md`를 함께 업데이트한다.** + - `Currently Implemented Features` 테이블: 새 서비스/기능 추가, 상태 변경(🚧→✅), 삭제된 항목 제거 + - `TUI Key Bindings` 테이블: 키 바인딩이 추가/변경/삭제된 경우 반영 + - `Usage` 섹션: 새로운 CLI 명령이나 플래그가 추가된 경우 반영 + - `Configuration` 섹션: 설정 형식이 변경된 경우 반영 +- PR 생성 전 README가 현재 구현 상태와 일치하는지 확인한다. diff --git a/README.md b/README.md index df0c5e6..5b39fe9 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,10 @@ contexts: | EC2 | SSM Session Manager (connect to EC2 instances) | ✅ Implemented | | VPC | VPC Browser (VPCs → subnets → available IPs) | ✅ Implemented | | RDS | RDS Browser (list, start/stop, failover, Aurora cluster support) | ✅ Implemented | -| Route53 | ListHostedZones | 🚧 Coming Soon | -| IAM | ListUsers | 🚧 Coming Soon | +| Route53 | Route53 Browser (hosted zones, DNS records) | ✅ Implemented | +| Secrets Manager | Secrets Browser (browse secrets, view key/value pairs) | ✅ Implemented | +| IAM | Access Key Browser (list keys with status, age, last used) | ✅ Implemented | +| IAM | Access Key Rotation (create → verify/apply → deactivate → delete) | ✅ Implemented | ## TUI Key Bindings @@ -83,6 +85,11 @@ contexts: | `/` | Filter (instances, IPs) | | `C` | Context switcher | | `s`/`x`/`f` | Start/Stop/Failover (RDS detail) | +| `r` | Rotate access key (IAM key detail) | +| `c` | Copy new key as export commands (IAM rotation result) | +| `a` | Apply new key to ~/.aws/credentials (IAM rotation result) | +| `d` | Deactivate old key (IAM rotation result) | +| `x` | Delete old inactive key (IAM rotation result) | | `q` (on service list) | Quit | ## Documentation diff --git a/go.mod b/go.mod index b42321f..9888617 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module unic go 1.24.0 require ( - github.com/aws/aws-sdk-go-v2 v1.41.4 + github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/config v1.32.12 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 @@ -20,9 +20,10 @@ require ( require ( github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4 // indirect diff --git a/go.sum b/go.sum index 1a1ca44..7b43d3c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= @@ -8,12 +10,18 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqb github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 h1:98Miqj16un1WLNyM1RjVDhXYumhqZrQfAeG8i4jPG6o= github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0/go.mod h1:T6ndRfdhnXLIY5oKBHjYZDVj706los2zGdpThppquvA= +github.com/aws/aws-sdk-go-v2/service/iam v1.53.7 h1:n9YLiWtX3+6pTLZWvRJmtq5JIB9NA/KFelyCg5fOlTU= +github.com/aws/aws-sdk-go-v2/service/iam v1.53.7/go.mod h1:sP46Vo6MeJcM4s0ZXcG2PFmfiSyixhIuC/74W52yKuk= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= diff --git a/internal/app/app.go b/internal/app/app.go index c173ea3..cdeb2f5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -31,6 +31,10 @@ const ( screenRoute53RecordDetail screenSecretList screenSecretDetail + screenIAMKeyList + screenIAMKeyDetail + screenIAMKeyRotateConfirm + screenIAMKeyRotateResult screenContextPicker screenContextAdd screenLoading @@ -101,13 +105,27 @@ type Model struct { route53RecordFilterActive bool selectedRoute53Record *awsservice.DNSRecord + // IAM credentials state + iamKeys []awsservice.AccessKey + iamKeyIdx int + selectedIAMKey *awsservice.AccessKey + iamRotationEnabled bool + iamRotateConfirm string // typed input for rotate confirmation + iamRotationOldKeyID string + iamNewKey *awsservice.NewAccessKey + iamCopyMsg string // feedback message for clipboard copy + iamRotationStatus string + iamNewKeyVerified bool + iamOldKeyDeleted bool + iamOldKeyInactive bool + // Secrets Manager browser state - secrets []awsservice.Secret - filteredSecrets []awsservice.Secret - secretIdx int - secretFilter string - secretFilterActive bool - selectedSecret *awsservice.SecretDetail + secrets []awsservice.Secret + filteredSecrets []awsservice.Secret + secretIdx int + secretFilter string + secretFilterActive bool + selectedSecret *awsservice.SecretDetail // Context picker configPath string @@ -240,6 +258,58 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.screen = screenSecretDetail return m, nil + case iamKeysLoadedMsg: + m.iamKeys = msg.keys + m.iamKeyIdx = 0 + m.screen = screenIAMKeyList + return m, nil + + case iamKeyCreatedMsg: + if msg.err != nil { + m.errMsg = msg.err.Error() + m.screen = screenError + return m, nil + } + m.iamNewKey = msg.newKey + m.iamCopyMsg = "" + m.iamRotationStatus = "" + m.iamNewKeyVerified = false + m.iamOldKeyInactive = false + m.iamOldKeyDeleted = false + m.screen = screenIAMKeyRotateResult + return m, nil + + case iamKeyVerifiedMsg: + if msg.err != nil { + m.iamRotationStatus = fmt.Sprintf("Verification failed: %s", msg.err) + return m, nil + } + m.iamNewKeyVerified = true + if msg.identity != nil { + m.iamRotationStatus = fmt.Sprintf("Verified new key as %s", msg.identity.Arn) + } else { + m.iamRotationStatus = "Verified new key" + } + return m, nil + + case iamKeyDeactivatedMsg: + if msg.err != nil { + m.iamRotationStatus = msg.err.Error() + return m, nil + } + m.iamOldKeyInactive = true + m.iamRotationStatus = fmt.Sprintf("Old key %s marked Inactive", msg.keyID) + return m, nil + + case iamKeyDeletedMsg: + if msg.err != nil { + m.iamRotationStatus = msg.err.Error() + return m, nil + } + m.iamOldKeyDeleted = true + m.iamRotationStatus = fmt.Sprintf("Old key %s deleted", msg.keyID) + return m, nil + case rdsActionDoneMsg: if msg.err != nil { m.errMsg = msg.err.Error() @@ -365,6 +435,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSecretList(msg) case screenSecretDetail: return m.updateSecretDetail(msg) + case screenIAMKeyList: + return m.updateIAMKeyList(msg) + case screenIAMKeyDetail: + return m.updateIAMKeyDetail(msg) + case screenIAMKeyRotateConfirm: + return m.updateIAMKeyRotateConfirm(msg) + case screenIAMKeyRotateResult: + return m.updateIAMKeyRotateResult(msg) case screenContextPicker: return m.updateContextPicker(msg) case screenContextAdd: @@ -434,6 +512,14 @@ func (m Model) updateFeatureList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case domain.FeatureSecretsBrowser: m.screen = screenLoading return m, m.loadSecrets() + case domain.FeatureListAccessKeys: + m.iamRotationEnabled = false + m.screen = screenLoading + return m, m.loadIAMKeys() + case domain.FeatureRotateAccessKey: + m.iamRotationEnabled = true + m.screen = screenLoading + return m, m.loadIAMKeys() } } } @@ -487,6 +573,14 @@ func (m Model) View() string { v = m.viewSecretList() case screenSecretDetail: v = m.viewSecretDetail() + case screenIAMKeyList: + v = m.viewIAMKeyList() + case screenIAMKeyDetail: + v = m.viewIAMKeyDetail() + case screenIAMKeyRotateConfirm: + v = m.viewIAMKeyRotateConfirm() + case screenIAMKeyRotateResult: + v = m.viewIAMKeyRotateResult() case screenContextPicker: v = m.viewContextPicker() case screenContextAdd: diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a3e1c30..cf528e8 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -588,3 +588,191 @@ func TestViewFitsTerminalHeight(t *testing.T) { t.Errorf("view output has %d lines, exceeds terminal height %d", len(lines), m.height) } } + +func TestIAMFeatureListContainsSeparateActions(t *testing.T) { + m := New(testConfig(), "") + + for _, svc := range m.services { + if svc.Name == domain.ServiceIAM { + m.features = svc.Features + break + } + } + + if len(m.features) != 2 { + t.Fatalf("expected 2 IAM features, got %d", len(m.features)) + } + if m.features[0].Kind != domain.FeatureListAccessKeys { + t.Fatalf("expected first IAM feature ListAccessKeys, got %s", m.features[0].Kind) + } + if m.features[1].Kind != domain.FeatureRotateAccessKey { + t.Fatalf("expected second IAM feature RotateAccessKey, got %s", m.features[1].Kind) + } +} + +func TestIAMKeyDetailHidesRotateActionInListMode(t *testing.T) { + m := New(testConfig(), "") + m.iamRotationEnabled = false + m.selectedIAMKey = &awsservice.AccessKey{ + AccessKeyID: "AKIATEST", + Status: "Active", + } + + view := m.viewIAMKeyDetail() + if !strings.Contains(view, "RotateAccessKey feature") { + t.Fatalf("expected list mode detail view to hide direct rotate action, got %q", view) + } +} + +func TestIAMKeyDetailShowsRotateActionInRotateMode(t *testing.T) { + m := New(testConfig(), "") + m.iamRotationEnabled = true + m.selectedIAMKey = &awsservice.AccessKey{ + AccessKeyID: "AKIATEST", + Status: "Active", + } + + view := m.viewIAMKeyDetail() + if !strings.Contains(view, "[r] Rotate key") { + t.Fatalf("expected rotate mode detail view to show rotate action, got %q", view) + } +} + +func TestIAMRotationResultRequiresApplyBeforeDeactivateForCredentialCurrentIdentity(t *testing.T) { + m := New(testConfig(), "") + m.cfg.AuthType = config.AuthTypeCredential + m.iamNewKey = &awsservice.NewAccessKey{ + AccessKeyID: "AKIANEWKEY", + SecretAccessKey: "secret", + } + m.iamRotationOldKeyID = "AKIAOLDKEY" + + if m.canDeactivateIAMOldKey() { + t.Fatal("expected deactivate to be blocked before apply/verify") + } + + view := m.viewIAMKeyRotateResult() + if !strings.Contains(view, "Apply to ~/.aws/credentials and verify") { + t.Fatalf("expected apply action in result view, got %q", view) + } + if !strings.Contains(view, "available after apply + verify") { + t.Fatalf("expected deactivate gating message, got %q", view) + } +} + +func TestIAMRotationResultAllowsDeactivateAfterVerify(t *testing.T) { + m := New(testConfig(), "") + m.cfg.AuthType = config.AuthTypeCredential + m.iamNewKey = &awsservice.NewAccessKey{ + AccessKeyID: "AKIANEWKEY", + SecretAccessKey: "secret", + } + m.iamRotationOldKeyID = "AKIAOLDKEY" + m.iamNewKeyVerified = true + + if !m.canDeactivateIAMOldKey() { + t.Fatal("expected deactivate to be allowed after verification") + } +} + +func TestIAMRotationResultRequiresNoApplyForSSOContext(t *testing.T) { + m := New(testConfig(), "") + m.cfg.AuthType = config.AuthTypeSSO + m.iamNewKey = &awsservice.NewAccessKey{ + AccessKeyID: "AKIANEWKEY", + SecretAccessKey: "secret", + } + m.iamRotationOldKeyID = "AKIAOLDKEY" + + if !m.canDeactivateIAMOldKey() { + t.Fatal("expected non-credential flow to allow immediate deactivate") + } +} + +func TestIAMRotationResultShowsApplyForLegacyCredentialContext(t *testing.T) { + m := New(testConfig(), "") + m.cfg.AuthType = config.AuthTypeDefault + m.cfg.Profile = "default" + m.cfg.RoleArn = "" + m.cfg.SSOStartURL = "" + m.cfg.SSOAccountID = "" + m.cfg.SSORoleName = "" + m.iamNewKey = &awsservice.NewAccessKey{ + AccessKeyID: "AKIANEWKEY", + SecretAccessKey: "secret", + } + m.iamRotationOldKeyID = "AKIAOLDKEY" + + if !m.requiresIAMCredentialApplyBeforeDeactivate() { + t.Fatal("expected legacy profile-based context to require apply/verify") + } + + view := m.viewIAMKeyRotateResult() + if !strings.Contains(view, "[a] Apply to ~/.aws/credentials and verify") { + t.Fatalf("expected apply action for legacy credential context, got %q", view) + } +} + +func TestIAMRotationResultShowsApplyForImplicitDefaultProfile(t *testing.T) { + m := New(testConfig(), "") + m.cfg.AuthType = config.AuthTypeDefault + m.cfg.Profile = "" + m.cfg.RoleArn = "" + m.cfg.SSOStartURL = "" + m.cfg.SSOAccountID = "" + m.cfg.SSORoleName = "" + m.iamNewKey = &awsservice.NewAccessKey{ + AccessKeyID: "AKIANEWKEY", + SecretAccessKey: "secret", + } + m.iamRotationOldKeyID = "AKIAOLDKEY" + + if !m.requiresIAMCredentialApplyBeforeDeactivate() { + t.Fatal("expected implicit default profile to require apply/verify") + } + + view := m.viewIAMKeyRotateResult() + if !strings.Contains(view, "[a] Apply to ~/.aws/credentials and verify") { + t.Fatalf("expected apply action for implicit default profile, got %q", view) + } +} + +func TestIAMRotationResultShowsDisabledApplyReasonForSSO(t *testing.T) { + m := New(testConfig(), "") + m.cfg.AuthType = config.AuthTypeSSO + m.iamNewKey = &awsservice.NewAccessKey{ + AccessKeyID: "AKIANEWKEY", + SecretAccessKey: "secret", + } + m.iamRotationOldKeyID = "AKIAOLDKEY" + + view := m.viewIAMKeyRotateResult() + if !strings.Contains(view, "disabled for auth:sso") { + t.Fatalf("expected disabled reason for sso flow, got %q", view) + } +} + +func TestRotateAccessKeyFeatureUsesCurrentIdentityFlow(t *testing.T) { + m := New(testConfig(), "") + + for _, svc := range m.services { + if svc.Name == domain.ServiceIAM { + m.features = svc.Features + break + } + } + m.screen = screenFeatureList + m.featIdx = 1 + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(Model) + if !model.iamRotationEnabled { + t.Fatal("expected IAM rotation mode to be enabled") + } + if model.screen != screenLoading { + t.Fatalf("expected loading screen, got %d", model.screen) + } + if cmd == nil { + t.Fatal("expected load IAM keys command") + } +} diff --git a/internal/app/messages.go b/internal/app/messages.go index 0d75265..51f5e08 100644 --- a/internal/app/messages.go +++ b/internal/app/messages.go @@ -82,3 +82,27 @@ type secretsLoadedMsg struct { type secretDetailLoadedMsg struct { detail *awsservice.SecretDetail } + +type iamKeysLoadedMsg struct { + keys []awsservice.AccessKey +} + +type iamKeyCreatedMsg struct { + newKey *awsservice.NewAccessKey + err error +} + +type iamKeyVerifiedMsg struct { + identity *awsservice.CallerIdentity + err error +} + +type iamKeyDeactivatedMsg struct { + keyID string + err error +} + +type iamKeyDeletedMsg struct { + keyID string + err error +} diff --git a/internal/app/screen_iam.go b/internal/app/screen_iam.go new file mode 100644 index 0000000..c2ce5b2 --- /dev/null +++ b/internal/app/screen_iam.go @@ -0,0 +1,490 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "unic/internal/auth" + "unic/internal/clipboard" + "unic/internal/config" + awsservice "unic/internal/services/aws" +) + +func (m Model) loadIAMKeys() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo, err := awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return errMsg{err: err} + } + m.awsRepo = repo + + keys, err := repo.ListAccessKeys(ctx) + if err != nil { + return errMsg{err: err} + } + if len(keys) == 0 { + return errMsg{err: fmt.Errorf("no access keys found")} + } + return iamKeysLoadedMsg{keys: keys} + } +} + +func (m Model) createIAMKey() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return iamKeyCreatedMsg{err: err} + } + } + + newKey, err := repo.CreateAccessKey(ctx) + return iamKeyCreatedMsg{newKey: newKey, err: err} + } +} + +func (m Model) verifyIAMKey() tea.Cmd { + return func() tea.Msg { + if m.iamNewKey == nil { + return iamKeyVerifiedMsg{err: fmt.Errorf("no new key available to verify")} + } + + if err := auth.UpdateSharedCredentialsProfile(m.cfg.Profile, m.iamNewKey.AccessKeyID, m.iamNewKey.SecretAccessKey); err != nil { + return iamKeyVerifiedMsg{err: err} + } + + ctx := context.Background() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return iamKeyVerifiedMsg{err: err} + } + } + + identity, err := repo.VerifyAccessKey(ctx, m.iamNewKey) + return iamKeyVerifiedMsg{identity: identity, err: err} + } +} + +func (m Model) deactivateIAMKey(oldKeyID string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return iamKeyDeactivatedMsg{keyID: oldKeyID, err: err} + } + } + + err := repo.DeactivateAccessKey(ctx, oldKeyID) + return iamKeyDeactivatedMsg{keyID: oldKeyID, err: err} + } +} + +func (m Model) deleteIAMKey(keyID string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + repo := m.awsRepo + if repo == nil { + var err error + repo, err = awsservice.NewAwsRepository(ctx, m.cfg) + if err != nil { + return iamKeyDeletedMsg{keyID: keyID, err: err} + } + } + + err := repo.DeleteAccessKey(ctx, keyID) + return iamKeyDeletedMsg{keyID: keyID, err: err} + } +} + +func (m Model) requiresIAMCredentialApplyBeforeDeactivate() bool { + if m.cfg == nil { + return false + } + + if m.cfg.AuthType == config.AuthTypeCredential { + return true + } + + // Legacy contexts may omit auth_type even though they are profile-based + // shared credentials sessions. Keep the safer handoff path available there too. + return m.cfg.AuthType == config.AuthTypeDefault && + m.cfg.RoleArn == "" && + m.cfg.SSOStartURL == "" && + m.cfg.SSOAccountID == "" && + m.cfg.SSORoleName == "" +} + +func (m Model) canDeactivateIAMOldKey() bool { + if m.iamNewKey == nil || m.iamRotationOldKeyID == "" || m.iamOldKeyInactive { + return false + } + if !m.requiresIAMCredentialApplyBeforeDeactivate() { + return true + } + return m.iamNewKeyVerified +} + +func (m Model) iamApplyActionLine() string { + if m.requiresIAMCredentialApplyBeforeDeactivate() { + if m.iamNewKeyVerified { + return selectedStyle.Render(" [a] Applied to ~/.aws/credentials and verified") + } + return normalStyle.Render(" [a] Apply to ~/.aws/credentials and verify") + } + authType := "default" + if m.cfg != nil && m.cfg.AuthType != "" { + authType = string(m.cfg.AuthType) + } + return dimStyle.Render(fmt.Sprintf(" [a] Apply to ~/.aws/credentials and verify (disabled for auth:%s)", authType)) +} + +func (m Model) updateIAMKeyList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + m.screen = screenFeatureList + m.iamKeyIdx = 0 + case "up", "k": + if m.iamKeyIdx > 0 { + m.iamKeyIdx-- + } + case "down", "j": + if m.iamKeyIdx < len(m.iamKeys)-1 { + m.iamKeyIdx++ + } + case "enter": + if len(m.iamKeys) > 0 && m.iamKeyIdx < len(m.iamKeys) { + selected := m.iamKeys[m.iamKeyIdx] + m.selectedIAMKey = &selected + m.screen = screenIAMKeyDetail + } + } + return m, nil +} + +func (m Model) updateIAMKeyDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + m.screen = screenIAMKeyList + case "r": + if m.iamRotationEnabled && m.selectedIAMKey != nil && m.selectedIAMKey.Status == "Active" { + m.iamRotateConfirm = "" + m.iamRotationOldKeyID = m.selectedIAMKey.AccessKeyID + m.iamNewKey = nil + m.iamCopyMsg = "" + m.iamRotationStatus = "" + m.iamNewKeyVerified = false + m.iamOldKeyInactive = false + m.iamOldKeyDeleted = false + m.screen = screenIAMKeyRotateConfirm + } + } + return m, nil +} + +func (m Model) updateIAMKeyRotateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + confirmTarget := "" + if m.selectedIAMKey != nil { + confirmTarget = m.selectedIAMKey.AccessKeyID + } + + switch msg.String() { + case "esc": + m.screen = screenIAMKeyDetail + case "enter": + if m.selectedIAMKey != nil && m.iamRotateConfirm == confirmTarget { + m.screen = screenLoading + return m, m.createIAMKey() + } + case "backspace": + if len(m.iamRotateConfirm) > 0 { + m.iamRotateConfirm = m.iamRotateConfirm[:len(m.iamRotateConfirm)-1] + } + default: + if runes := msg.Runes; len(runes) > 0 { + m.iamRotateConfirm += string(runes) + } + } + return m, nil +} + +func (m Model) updateIAMKeyRotateResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "c": + if m.iamNewKey != nil { + exportStr := fmt.Sprintf( + "export AWS_ACCESS_KEY_ID=%s\nexport AWS_SECRET_ACCESS_KEY=%s", + m.iamNewKey.AccessKeyID, m.iamNewKey.SecretAccessKey, + ) + if err := clipboard.Copy(exportStr); err != nil { + m.iamCopyMsg = fmt.Sprintf("Clipboard error: %s", err) + } else { + m.iamCopyMsg = "Copied to clipboard!" + } + } + case "a": + if m.requiresIAMCredentialApplyBeforeDeactivate() && m.iamNewKey != nil && !m.iamNewKeyVerified { + m.iamRotationStatus = "Applying new key to ~/.aws/credentials and verifying..." + return m, m.verifyIAMKey() + } + case "d": + if m.canDeactivateIAMOldKey() { + return m, m.deactivateIAMKey(m.iamRotationOldKeyID) + } + case "x": + if m.iamOldKeyInactive && !m.iamOldKeyDeleted && m.iamRotationOldKeyID != "" { + return m, m.deleteIAMKey(m.iamRotationOldKeyID) + } + case "q", "esc": + m.iamRotationOldKeyID = "" + m.iamNewKey = nil + m.iamCopyMsg = "" + m.iamRotationStatus = "" + m.iamNewKeyVerified = false + m.iamOldKeyInactive = false + m.iamOldKeyDeleted = false + m.screen = screenLoading + return m, m.loadIAMKeys() + } + return m, nil +} + +// --- View functions --- + +func (m Model) viewIAMKeyList() string { + var b strings.Builder + b.WriteString(m.renderStatusBar()) + title := "IAM Access Keys" + if m.iamRotationEnabled { + title = "Rotate IAM Access Key" + } + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n\n") + + if m.iamRotationEnabled { + b.WriteString(dimStyle.Render(" User: Current identity")) + b.WriteString("\n\n") + } + + if len(m.iamKeys) == 0 { + b.WriteString(dimStyle.Render(" No access keys found")) + b.WriteString("\n") + } else { + visibleLines := max(m.height-8, 5) + start := 0 + if m.iamKeyIdx >= visibleLines { + start = m.iamKeyIdx - visibleLines + 1 + } + end := min(start+visibleLines, len(m.iamKeys)) + + for i := start; i < end; i++ { + key := m.iamKeys[i] + cursor := " " + style := normalStyle + if i == m.iamKeyIdx { + cursor = "> " + style = selectedStyle + } + title := key.DisplayTitle() + if key.IsAged() { + title = errorStyle.Render(title) + cursor = errorStyle.Render(cursor) + } + if i == m.iamKeyIdx && !key.IsAged() { + title = style.Render(fmt.Sprintf("%s%s", cursor, key.DisplayTitle())) + } else if key.IsAged() { + title = fmt.Sprintf("%s%s", cursor, title) + } else { + title = fmt.Sprintf("%s%s", cursor, key.DisplayTitle()) + } + b.WriteString(title) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d keys", len(m.iamKeys)))) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("↑/↓: navigate • enter: detail • esc: back • H: home")) + return b.String() +} + +func (m Model) viewIAMKeyDetail() string { + if m.selectedIAMKey == nil { + return "" + } + k := m.selectedIAMKey + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(titleStyle.Render("Access Key Detail")) + b.WriteString("\n\n") + + labelStyle := lipgloss.NewStyle().Width(16) + b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Access Key ID"), k.AccessKeyID))) + b.WriteString("\n") + + statusStr := k.Status + if k.Status == "Active" { + statusStr = selectedStyle.Render(k.Status) + } else { + statusStr = dimStyle.Render(k.Status) + } + b.WriteString(fmt.Sprintf(" %s%s", labelStyle.Render("Status"), statusStr)) + b.WriteString("\n") + + b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Created"), k.CreateDate.Format(time.DateOnly)))) + b.WriteString("\n") + + ageStr := fmt.Sprintf("%d days", k.Age()) + if k.IsAged() { + ageStr = errorStyle.Render(fmt.Sprintf("%d days ⚠ (>90 days)", k.Age())) + } + b.WriteString(fmt.Sprintf(" %s%s", labelStyle.Render("Age"), ageStr)) + b.WriteString("\n") + + lastUsed := dimStyle.Render("Never") + if !k.LastUsed.IsZero() { + lastUsed = k.LastUsed.Format(time.DateOnly) + } + b.WriteString(fmt.Sprintf(" %s%s", labelStyle.Render("Last Used"), lastUsed)) + b.WriteString("\n") + + if k.ServiceName != "" && k.ServiceName != "N/A" { + b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Last Service"), k.ServiceName))) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(titleStyle.Render("Actions")) + b.WriteString("\n") + if !m.iamRotationEnabled { + b.WriteString(dimStyle.Render(" Rotation is available from the RotateAccessKey feature")) + b.WriteString("\n") + } else if k.Status == "Active" { + b.WriteString(normalStyle.Render(" [r] Rotate key (create new → verify/apply → deactivate)")) + b.WriteString("\n") + } else { + b.WriteString(dimStyle.Render(" [r] Rotate key (inactive — cannot rotate)")) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("esc: back • H: home")) + return b.String() +} + +func (m Model) viewIAMKeyRotateConfirm() string { + if m.selectedIAMKey == nil { + return "" + } + k := m.selectedIAMKey + + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(errorStyle.Render("Confirm Key Rotation")) + b.WriteString("\n\n") + + b.WriteString(normalStyle.Render(" You are about to rotate access key:")) + b.WriteString("\n") + b.WriteString(selectedStyle.Render(fmt.Sprintf(" %s", k.AccessKeyID))) + b.WriteString("\n\n") + b.WriteString(normalStyle.Render(" This will:")) + b.WriteString("\n") + b.WriteString(normalStyle.Render(" 1. Create a new access key")) + b.WriteString("\n") + b.WriteString(normalStyle.Render(" 2. Let you verify and apply the new key")) + b.WriteString("\n") + b.WriteString(normalStyle.Render(" 3. Deactivate the old key only when you confirm")) + b.WriteString("\n\n") + b.WriteString(normalStyle.Render(" Type the access key ID to confirm:")) + b.WriteString("\n") + b.WriteString(filterStyle.Render(fmt.Sprintf(" %s▏", m.iamRotateConfirm))) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" enter: confirm • esc: cancel")) + return b.String() +} + +func (m Model) viewIAMKeyRotateResult() string { + if m.iamNewKey == nil { + return "" + } + var b strings.Builder + b.WriteString(m.renderStatusBar()) + b.WriteString(selectedStyle.Render("New Access Key Created")) + b.WriteString("\n\n") + + b.WriteString(normalStyle.Render(" New credentials (shown once only):")) + b.WriteString("\n\n") + + labelStyle := lipgloss.NewStyle().Width(22) + b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Access Key ID"), m.iamNewKey.AccessKeyID))) + b.WriteString("\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Secret Access Key"), m.iamNewKey.SecretAccessKey))) + b.WriteString("\n\n") + + if m.iamRotationOldKeyID != "" { + oldKeyStatus := "Pending" + if m.iamOldKeyInactive { + oldKeyStatus = "Inactive" + } + if m.iamOldKeyDeleted { + oldKeyStatus = "Deleted" + } + b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Old Key"), m.iamRotationOldKeyID))) + b.WriteString("\n") + b.WriteString(normalStyle.Render(fmt.Sprintf(" %s%s", labelStyle.Render("Old Key Status"), oldKeyStatus))) + b.WriteString("\n\n") + } + + if m.iamCopyMsg != "" { + b.WriteString(selectedStyle.Render(fmt.Sprintf(" %s", m.iamCopyMsg))) + b.WriteString("\n\n") + } + + if m.iamRotationStatus != "" { + b.WriteString(selectedStyle.Render(fmt.Sprintf(" %s", m.iamRotationStatus))) + b.WriteString("\n\n") + } + + b.WriteString(titleStyle.Render("Actions")) + b.WriteString("\n") + b.WriteString(normalStyle.Render(" [c] Copy as export commands")) + b.WriteString("\n") + b.WriteString(m.iamApplyActionLine()) + b.WriteString("\n") + if m.canDeactivateIAMOldKey() { + b.WriteString(normalStyle.Render(" [d] Deactivate old key")) + } else if m.iamOldKeyInactive { + b.WriteString(dimStyle.Render(" [d] Old key already inactive")) + } else if m.requiresIAMCredentialApplyBeforeDeactivate() { + b.WriteString(dimStyle.Render(" [d] Deactivate old key (available after apply + verify)")) + } else { + b.WriteString(dimStyle.Render(" [d] Deactivate old key")) + } + b.WriteString("\n") + if m.iamOldKeyInactive && !m.iamOldKeyDeleted { + b.WriteString(normalStyle.Render(" [x] Delete old inactive key")) + } else if m.iamOldKeyDeleted { + b.WriteString(dimStyle.Render(" [x] Old key already deleted")) + } else { + b.WriteString(dimStyle.Render(" [x] Delete old key (available after deactivation)")) + } + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" esc: back to key list")) + return b.String() +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f526637..e3f5b0f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -4,14 +4,13 @@ import ( "context" "fmt" "os" - "os/exec" - "runtime" "strings" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/sts" + "unic/internal/clipboard" "unic/internal/config" awsservice "unic/internal/services/aws" ) @@ -113,7 +112,7 @@ func postSwitchAssumeRole(cfg *config.Config) (string, error) { var sb strings.Builder sb.WriteString(fmt.Sprintf("Assumed role: %s\n", cfg.RoleArn)) - if err := copyToClipboard(exportStr); err != nil { + if err := clipboard.Copy(exportStr); err != nil { sb.WriteString("\nClipboard unavailable. Copy and paste the following:\n\n") sb.WriteString(exportStr) } else { @@ -139,21 +138,3 @@ func verifyIdentity(cfg *config.Config) string { return fmt.Sprintf(" Identity: %s (account: %s)", identity.Arn, identity.Account) } -func copyToClipboard(text string) error { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("pbcopy") - case "linux": - if _, err := exec.LookPath("xclip"); err == nil { - cmd = exec.Command("xclip", "-selection", "clipboard") - } else { - cmd = exec.Command("xsel", "--clipboard", "--input") - } - default: - return fmt.Errorf("clipboard not supported on %s", runtime.GOOS) - } - - cmd.Stdin = strings.NewReader(text) - return cmd.Run() -} diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go new file mode 100644 index 0000000..c5f0022 --- /dev/null +++ b/internal/auth/credentials.go @@ -0,0 +1,122 @@ +package auth + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func UpdateSharedCredentialsProfile(profile, accessKeyID, secretAccessKey string) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("could not determine home directory: %w", err) + } + return updateSharedCredentialsProfileAtPath(filepath.Join(home, ".aws", "credentials"), profile, accessKeyID, secretAccessKey) +} + +func updateSharedCredentialsProfileAtPath(path, profile, accessKeyID, secretAccessKey string) error { + if profile == "" { + profile = "default" + } + + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read credentials file: %w", err) + } + + updated := updateProfileBlock(string(data), profile, accessKeyID, secretAccessKey) + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("failed to prepare credentials directory: %w", err) + } + if err := os.WriteFile(path, []byte(updated), 0o600); err != nil { + return fmt.Errorf("failed to write credentials file: %w", err) + } + return nil +} + +func updateProfileBlock(contents, profile, accessKeyID, secretAccessKey string) string { + if profile == "" { + profile = "default" + } + + lines := []string{} + if contents != "" { + lines = strings.Split(strings.ReplaceAll(contents, "\r\n", "\n"), "\n") + } + + header := "[" + profile + "]" + var out []string + found := false + inTarget := false + inserted := false + + appendProfile := func() { + if inserted { + return + } + if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" { + out = append(out, "") + } + out = append(out, + header, + "aws_access_key_id = "+accessKeyID, + "aws_secret_access_key = "+secretAccessKey, + ) + inserted = true + } + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + if inTarget && !inserted { + appendProfile() + } + inTarget = trimmed == header + if inTarget { + found = true + } + if !inTarget { + out = append(out, line) + continue + } + continue + } + + if !inTarget { + out = append(out, line) + continue + } + + key := trimmed + if idx := strings.Index(trimmed, "="); idx >= 0 { + key = strings.TrimSpace(trimmed[:idx]) + } + switch key { + case "aws_access_key_id", "aws_secret_access_key", "aws_session_token": + continue + default: + if !inserted { + out = append(out, + header, + "aws_access_key_id = "+accessKeyID, + "aws_secret_access_key = "+secretAccessKey, + ) + inserted = true + } + out = append(out, line) + } + } + + if found && !inserted { + appendProfile() + } + if !found { + appendProfile() + } + + result := strings.Join(out, "\n") + result = strings.TrimRight(result, "\n") + "\n" + return result +} diff --git a/internal/auth/credentials_test.go b/internal/auth/credentials_test.go new file mode 100644 index 0000000..d0b6e88 --- /dev/null +++ b/internal/auth/credentials_test.go @@ -0,0 +1,78 @@ +package auth + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUpdateSharedCredentialsProfileAtPath_ReplacesExistingProfile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "credentials") + + input := `[default] +aws_access_key_id = OLDKEY +aws_secret_access_key = OLDSECRET +aws_session_token = OLDTOKEN + +[other] +aws_access_key_id = OTHER +aws_secret_access_key = OTHERSECRET +` + if err := os.WriteFile(path, []byte(input), 0o600); err != nil { + t.Fatal(err) + } + + if err := updateSharedCredentialsProfileAtPath(path, "default", "NEWKEY", "NEWSECRET"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "[default]\naws_access_key_id = NEWKEY\naws_secret_access_key = NEWSECRET") { + t.Fatalf("expected updated default profile, got:\n%s", text) + } + if strings.Contains(text, "aws_session_token") { + t.Fatalf("expected aws_session_token to be removed, got:\n%s", text) + } + if !strings.Contains(text, "[other]\naws_access_key_id = OTHER\naws_secret_access_key = OTHERSECRET") { + t.Fatalf("expected other profile to be preserved, got:\n%s", text) + } +} + +func TestUpdateSharedCredentialsProfileAtPath_AppendsMissingProfile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "credentials") + + input := `[other] +aws_access_key_id = OTHER +aws_secret_access_key = OTHERSECRET +` + if err := os.WriteFile(path, []byte(input), 0o600); err != nil { + t.Fatal(err) + } + + if err := updateSharedCredentialsProfileAtPath(path, "new-profile", "NEWKEY", "NEWSECRET"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "[new-profile]\naws_access_key_id = NEWKEY\naws_secret_access_key = NEWSECRET") { + t.Fatalf("expected new profile to be appended, got:\n%s", text) + } +} + +func TestUpdateProfileBlock_UsesDefaultWhenProfileEmpty(t *testing.T) { + updated := updateProfileBlock("", "", "NEWKEY", "NEWSECRET") + if !strings.Contains(updated, "[default]\naws_access_key_id = NEWKEY\naws_secret_access_key = NEWSECRET") { + t.Fatalf("expected default profile block, got:\n%s", updated) + } +} diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go new file mode 100644 index 0000000..c5c40a8 --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -0,0 +1,28 @@ +package clipboard + +import ( + "fmt" + "os/exec" + "runtime" + "strings" +) + +// Copy copies text to the system clipboard. +func Copy(text string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("pbcopy") + case "linux": + if _, err := exec.LookPath("xclip"); err == nil { + cmd = exec.Command("xclip", "-selection", "clipboard") + } else { + cmd = exec.Command("xsel", "--clipboard", "--input") + } + default: + return fmt.Errorf("clipboard not supported on %s", runtime.GOOS) + } + + cmd.Stdin = strings.NewReader(text) + return cmd.Run() +} diff --git a/internal/config/config.go b/internal/config/config.go index 1b690da..637020e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,9 +19,9 @@ type fileConfig struct { DefaultRegion string `yaml:"default_region"` // New contexts-based format - Current string `yaml:"current"` - Defaults fileDefaults `yaml:"defaults"` - Contexts []contextEntry `yaml:"contexts"` + Current string `yaml:"current"` + Defaults fileDefaults `yaml:"defaults"` + Contexts []contextEntry `yaml:"contexts"` } type fileDefaults struct { @@ -66,6 +66,21 @@ type Config struct { SSORoleName string } +func normalizeAuthType(value string) AuthType { + switch value { + case "": + return AuthTypeDefault + case "sso": + return AuthTypeSSO + case "credential", "credentials": + return AuthTypeCredential + case "assume_role", "assume-role": + return AuthTypeAssumeRole + default: + return AuthType(value) + } +} + // ContextInfo holds summary information about a context for listing. type ContextInfo struct { Name string @@ -114,7 +129,7 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) { for _, ctx := range fc.Contexts { if ctx.Name == fc.Current { contextName = ctx.Name - authType = AuthType(ctx.AuthType) + authType = normalizeAuthType(ctx.AuthType) if ctx.Profile != "" { profile = ctx.Profile } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 744db33..a546e2c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -338,6 +338,43 @@ contexts: } } +func TestContextWithCredentialsAliasAuthType(t *testing.T) { + dir := t.TempDir() + path := writeUnicConfig(t, dir, ` +current: default-cred +contexts: + - name: default-cred + profile: default + auth_type: credentials +`) + cfg, err := Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + if cfg.AuthType != AuthTypeCredential { + t.Errorf("expected auth_type alias 'credentials' to normalize to 'credential', got '%s'", cfg.AuthType) + } +} + +func TestContextWithAssumeRoleAliasAuthType(t *testing.T) { + dir := t.TempDir() + path := writeUnicConfig(t, dir, ` +current: prod-admin +contexts: + - name: prod-admin + profile: default + auth_type: assume-role + role_arn: arn:aws:iam::111111111111:role/Admin +`) + cfg, err := Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + if cfg.AuthType != AuthTypeAssumeRole { + t.Errorf("expected auth_type alias 'assume-role' to normalize to 'assume_role', got '%s'", cfg.AuthType) + } +} + func TestContextWithAssumeRoleAuthType(t *testing.T) { dir := t.TempDir() path := writeUnicConfig(t, dir, ` diff --git a/internal/domain/catalog.go b/internal/domain/catalog.go index 9b05f5d..3deab82 100644 --- a/internal/domain/catalog.go +++ b/internal/domain/catalog.go @@ -48,5 +48,18 @@ func Catalog() []Service { }, }, }, + { + Name: ServiceIAM, + Features: []Feature{ + { + Kind: FeatureListAccessKeys, + Description: "List IAM access keys with status, age, and last used date", + }, + { + Kind: FeatureRotateAccessKey, + Description: "Rotate the current session IAM access key with verify and cleanup steps", + }, + }, + }, } } diff --git a/internal/domain/catalog_test.go b/internal/domain/catalog_test.go index 21595c6..e35835e 100644 --- a/internal/domain/catalog_test.go +++ b/internal/domain/catalog_test.go @@ -84,3 +84,34 @@ func TestRDSHasBrowserFeature(t *testing.T) { t.Error("RDS service not found in catalog") } + +func TestIAMHasAccessKeyFeatures(t *testing.T) { + services := Catalog() + + for _, svc := range services { + if svc.Name != ServiceIAM { + continue + } + + foundList := false + foundRotate := false + for _, feat := range svc.Features { + if feat.Kind == FeatureListAccessKeys { + foundList = true + } + if feat.Kind == FeatureRotateAccessKey { + foundRotate = true + } + } + + if !foundList { + t.Error("IAM service should have ListAccessKeys feature") + } + if !foundRotate { + t.Error("IAM service should have RotateAccessKey feature") + } + return + } + + t.Error("IAM service not found in catalog") +} diff --git a/internal/domain/model.go b/internal/domain/model.go index 0d7eeb4..ac1ebf3 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -9,17 +9,20 @@ const ( ServiceRDS AwsService = "RDS" ServiceRoute53 AwsService = "Route53" ServiceSecretsManager AwsService = "Secrets Manager" + ServiceIAM AwsService = "IAM" ) // FeatureKind represents a specific feature within a service. type FeatureKind string const ( - FeatureSSMSession FeatureKind = "SSM Sessions Manager" - FeatureVPCBrowser FeatureKind = "VPC Browser" - FeatureRDSBrowser FeatureKind = "RDS Browser" - FeatureRoute53Browser FeatureKind = "Route53 Browser" - FeatureSecretsBrowser FeatureKind = "Secrets Manager Browser" + FeatureSSMSession FeatureKind = "SSM Sessions Manager" + FeatureVPCBrowser FeatureKind = "VPC Browser" + FeatureRDSBrowser FeatureKind = "RDS Browser" + FeatureRoute53Browser FeatureKind = "Route53 Browser" + FeatureSecretsBrowser FeatureKind = "Secrets Manager Browser" + FeatureListAccessKeys FeatureKind = "ListAccessKeys" + FeatureRotateAccessKey FeatureKind = "RotateAccessKey" ) // Feature describes a selectable feature under an AWS service. diff --git a/internal/services/aws/iam.go b/internal/services/aws/iam.go new file mode 100644 index 0000000..82d24e8 --- /dev/null +++ b/internal/services/aws/iam.go @@ -0,0 +1,132 @@ +package aws + +import ( + "context" + "fmt" + "strings" + "time" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +var getCallerIdentityWithConfig = func(ctx context.Context, cfg awssdk.Config) (*sts.GetCallerIdentityOutput, error) { + return sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) +} + +// ListAccessKeys returns access keys for the current IAM identity. +func (r *AwsRepository) ListAccessKeys(ctx context.Context) ([]AccessKey, error) { + output, err := r.IAMClient.ListAccessKeys(ctx, &iam.ListAccessKeysInput{}) + if err != nil { + return nil, fmt.Errorf("failed to list access keys: %w", err) + } + + keys := make([]AccessKey, 0, len(output.AccessKeyMetadata)) + for _, meta := range output.AccessKeyMetadata { + key := AccessKey{ + AccessKeyID: awssdk.ToString(meta.AccessKeyId), + Status: string(meta.Status), + } + if meta.CreateDate != nil { + key.CreateDate = *meta.CreateDate + } + + // Fetch last-used info + lastUsed, err := r.IAMClient.GetAccessKeyLastUsed(ctx, &iam.GetAccessKeyLastUsedInput{ + AccessKeyId: meta.AccessKeyId, + }) + if err == nil && lastUsed.AccessKeyLastUsed != nil { + if lastUsed.AccessKeyLastUsed.LastUsedDate != nil { + key.LastUsed = *lastUsed.AccessKeyLastUsed.LastUsedDate + } + key.ServiceName = awssdk.ToString(lastUsed.AccessKeyLastUsed.ServiceName) + } + + keys = append(keys, key) + } + return keys, nil +} + +// CreateAccessKey creates a new IAM access key for the current IAM identity. +func (r *AwsRepository) CreateAccessKey(ctx context.Context) (*NewAccessKey, error) { + createOut, err := r.IAMClient.CreateAccessKey(ctx, &iam.CreateAccessKeyInput{}) + if err != nil { + return nil, fmt.Errorf("failed to create new access key: %w", err) + } + + return &NewAccessKey{ + AccessKeyID: awssdk.ToString(createOut.AccessKey.AccessKeyId), + SecretAccessKey: awssdk.ToString(createOut.AccessKey.SecretAccessKey), + }, nil +} + +// DeactivateAccessKey marks an existing access key inactive. +func (r *AwsRepository) DeactivateAccessKey(ctx context.Context, oldKeyID string) error { + updateInput := &iam.UpdateAccessKeyInput{ + AccessKeyId: awssdk.String(oldKeyID), + Status: iamtypes.StatusTypeInactive, + } + if _, err := r.IAMClient.UpdateAccessKey(ctx, updateInput); err != nil { + return fmt.Errorf("failed to deactivate access key %s: %w", oldKeyID, err) + } + return nil +} + +// DeleteAccessKey permanently deletes an IAM access key. +func (r *AwsRepository) DeleteAccessKey(ctx context.Context, keyID string) error { + input := &iam.DeleteAccessKeyInput{ + AccessKeyId: awssdk.String(keyID), + } + if _, err := r.IAMClient.DeleteAccessKey(ctx, input); err != nil { + return fmt.Errorf("failed to delete access key %s: %w", keyID, err) + } + return nil +} + +// VerifyAccessKey confirms the provided static credentials are usable. +func (r *AwsRepository) VerifyAccessKey(ctx context.Context, key *NewAccessKey) (*CallerIdentity, error) { + cfg := awssdk.Config{ + Region: r.Region, + Credentials: credentials.NewStaticCredentialsProvider( + key.AccessKeyID, + key.SecretAccessKey, + "", + ), + } + + var lastErr error + for attempt := 0; attempt < 5; attempt++ { + out, err := getCallerIdentityWithConfig(ctx, cfg) + if err == nil { + return &CallerIdentity{ + Account: awssdk.ToString(out.Account), + Arn: awssdk.ToString(out.Arn), + UserID: awssdk.ToString(out.UserId), + }, nil + } + + lastErr = err + if !isRetryableAccessKeyVerificationError(err) || attempt == 4 { + break + } + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("failed to verify new access key: %w", ctx.Err()) + case <-time.After(time.Duration(attempt+1) * 250 * time.Millisecond): + } + } + + return nil, fmt.Errorf("failed to verify new access key: %w", lastErr) +} + +func isRetryableAccessKeyVerificationError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "InvalidClientTokenId") || strings.Contains(msg, "InvalidAccessKeyId") +} diff --git a/internal/services/aws/iam_model.go b/internal/services/aws/iam_model.go new file mode 100644 index 0000000..f16e590 --- /dev/null +++ b/internal/services/aws/iam_model.go @@ -0,0 +1,52 @@ +package aws + +import ( + "fmt" + "strings" + "time" +) + +// AccessKey holds information about an IAM access key. +type AccessKey struct { + AccessKeyID string + Status string + CreateDate time.Time + LastUsed time.Time + ServiceName string +} + +// NewAccessKey holds credentials for a newly created access key. +// The secret is only available at creation time. +type NewAccessKey struct { + AccessKeyID string + SecretAccessKey string +} + +// Age returns the number of days since the key was created. +func (k AccessKey) Age() int { + return int(time.Since(k.CreateDate).Hours() / 24) +} + +// IsAged returns true if the key is older than 90 days. +func (k AccessKey) IsAged() bool { + return k.Age() > 90 +} + +// DisplayTitle returns a formatted string for list display. +func (k AccessKey) DisplayTitle() string { + age := k.Age() + ageStr := fmt.Sprintf("%dd", age) + if age > 90 { + ageStr = fmt.Sprintf("%dd ⚠", age) + } + lastUsed := "never" + if !k.LastUsed.IsZero() { + lastUsed = k.LastUsed.Format(time.DateOnly) + } + return fmt.Sprintf("%s [%s] age:%s last:%s", k.AccessKeyID, k.Status, ageStr, lastUsed) +} + +// FilterText returns a lowercase string for keyword matching. +func (k AccessKey) FilterText() string { + return strings.ToLower(fmt.Sprintf("%s %s %s", k.AccessKeyID, k.Status, k.ServiceName)) +} diff --git a/internal/services/aws/iam_test.go b/internal/services/aws/iam_test.go new file mode 100644 index 0000000..74775a5 --- /dev/null +++ b/internal/services/aws/iam_test.go @@ -0,0 +1,367 @@ +package aws + +import ( + "context" + "fmt" + "testing" + "time" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +// mockIAMClient implements IAMClientAPI for testing. +type mockIAMClient struct { + listAccessKeysFunc func(ctx context.Context, params *iam.ListAccessKeysInput, optFns ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) + getAccessKeyLastUsedFunc func(ctx context.Context, params *iam.GetAccessKeyLastUsedInput, optFns ...func(*iam.Options)) (*iam.GetAccessKeyLastUsedOutput, error) + createAccessKeyFunc func(ctx context.Context, params *iam.CreateAccessKeyInput, optFns ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) + updateAccessKeyFunc func(ctx context.Context, params *iam.UpdateAccessKeyInput, optFns ...func(*iam.Options)) (*iam.UpdateAccessKeyOutput, error) + deleteAccessKeyFunc func(ctx context.Context, params *iam.DeleteAccessKeyInput, optFns ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) +} + +func (m *mockIAMClient) ListAccessKeys(ctx context.Context, params *iam.ListAccessKeysInput, optFns ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) { + return m.listAccessKeysFunc(ctx, params, optFns...) +} + +func (m *mockIAMClient) GetAccessKeyLastUsed(ctx context.Context, params *iam.GetAccessKeyLastUsedInput, optFns ...func(*iam.Options)) (*iam.GetAccessKeyLastUsedOutput, error) { + if m.getAccessKeyLastUsedFunc != nil { + return m.getAccessKeyLastUsedFunc(ctx, params, optFns...) + } + return &iam.GetAccessKeyLastUsedOutput{ + AccessKeyLastUsed: &iamtypes.AccessKeyLastUsed{ + ServiceName: awssdk.String("N/A"), + }, + }, nil +} + +func (m *mockIAMClient) CreateAccessKey(ctx context.Context, params *iam.CreateAccessKeyInput, optFns ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) { + if m.createAccessKeyFunc != nil { + return m.createAccessKeyFunc(ctx, params, optFns...) + } + return &iam.CreateAccessKeyOutput{}, nil +} + +func (m *mockIAMClient) UpdateAccessKey(ctx context.Context, params *iam.UpdateAccessKeyInput, optFns ...func(*iam.Options)) (*iam.UpdateAccessKeyOutput, error) { + if m.updateAccessKeyFunc != nil { + return m.updateAccessKeyFunc(ctx, params, optFns...) + } + return &iam.UpdateAccessKeyOutput{}, nil +} + +func (m *mockIAMClient) DeleteAccessKey(ctx context.Context, params *iam.DeleteAccessKeyInput, optFns ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) { + if m.deleteAccessKeyFunc != nil { + return m.deleteAccessKeyFunc(ctx, params, optFns...) + } + return &iam.DeleteAccessKeyOutput{}, nil +} + +// --- ListAccessKeys tests --- + +func TestListAccessKeys_Success(t *testing.T) { + created := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC) + lastUsed := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) + + mock := &mockIAMClient{ + listAccessKeysFunc: func(_ context.Context, _ *iam.ListAccessKeysInput, _ ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) { + return &iam.ListAccessKeysOutput{ + AccessKeyMetadata: []iamtypes.AccessKeyMetadata{ + { + AccessKeyId: awssdk.String("AKIAIOSFODNN7EXAMPLE"), + Status: iamtypes.StatusTypeActive, + CreateDate: &created, + }, + }, + }, nil + }, + getAccessKeyLastUsedFunc: func(_ context.Context, params *iam.GetAccessKeyLastUsedInput, _ ...func(*iam.Options)) (*iam.GetAccessKeyLastUsedOutput, error) { + return &iam.GetAccessKeyLastUsedOutput{ + AccessKeyLastUsed: &iamtypes.AccessKeyLastUsed{ + LastUsedDate: &lastUsed, + ServiceName: awssdk.String("s3"), + }, + }, nil + }, + } + + repo := &AwsRepository{IAMClient: mock} + keys, err := repo.ListAccessKeys(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(keys) != 1 { + t.Fatalf("expected 1 key, got %d", len(keys)) + } + + key := keys[0] + if key.AccessKeyID != "AKIAIOSFODNN7EXAMPLE" { + t.Errorf("expected key ID 'AKIAIOSFODNN7EXAMPLE', got %q", key.AccessKeyID) + } + if key.Status != "Active" { + t.Errorf("expected status 'Active', got %q", key.Status) + } + if !key.LastUsed.Equal(lastUsed) { + t.Errorf("expected last used %v, got %v", lastUsed, key.LastUsed) + } + if key.ServiceName != "s3" { + t.Errorf("expected service 's3', got %q", key.ServiceName) + } +} + +func TestListAccessKeys_Empty(t *testing.T) { + mock := &mockIAMClient{ + listAccessKeysFunc: func(_ context.Context, _ *iam.ListAccessKeysInput, _ ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) { + return &iam.ListAccessKeysOutput{ + AccessKeyMetadata: []iamtypes.AccessKeyMetadata{}, + }, nil + }, + } + + repo := &AwsRepository{IAMClient: mock} + keys, err := repo.ListAccessKeys(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(keys) != 0 { + t.Errorf("expected empty slice, got %d", len(keys)) + } +} + +func TestListAccessKeys_Error(t *testing.T) { + mock := &mockIAMClient{ + listAccessKeysFunc: func(_ context.Context, _ *iam.ListAccessKeysInput, _ ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) { + return nil, fmt.Errorf("access denied") + }, + } + + repo := &AwsRepository{IAMClient: mock} + _, err := repo.ListAccessKeys(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +// --- Access key lifecycle tests --- + +func TestCreateAccessKey_Success(t *testing.T) { + mock := &mockIAMClient{ + createAccessKeyFunc: func(_ context.Context, _ *iam.CreateAccessKeyInput, _ ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) { + return &iam.CreateAccessKeyOutput{ + AccessKey: &iamtypes.AccessKey{ + AccessKeyId: awssdk.String("AKIANEWKEY1234567890"), + SecretAccessKey: awssdk.String("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"), + }, + }, nil + }, + } + + repo := &AwsRepository{IAMClient: mock} + newKey, err := repo.CreateAccessKey(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if newKey.AccessKeyID != "AKIANEWKEY1234567890" { + t.Errorf("expected new key ID 'AKIANEWKEY1234567890', got %q", newKey.AccessKeyID) + } + if newKey.SecretAccessKey != "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" { + t.Errorf("unexpected secret key") + } +} + +func TestCreateAccessKey_Error(t *testing.T) { + mock := &mockIAMClient{ + createAccessKeyFunc: func(_ context.Context, _ *iam.CreateAccessKeyInput, _ ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) { + return nil, fmt.Errorf("limit exceeded") + }, + } + + repo := &AwsRepository{IAMClient: mock} + _, err := repo.CreateAccessKey(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestDeactivateAccessKey_Success(t *testing.T) { + var deactivatedKeyID string + mock := &mockIAMClient{ + updateAccessKeyFunc: func(_ context.Context, params *iam.UpdateAccessKeyInput, _ ...func(*iam.Options)) (*iam.UpdateAccessKeyOutput, error) { + deactivatedKeyID = awssdk.ToString(params.AccessKeyId) + if params.Status != iamtypes.StatusTypeInactive { + t.Errorf("expected status Inactive, got %v", params.Status) + } + return &iam.UpdateAccessKeyOutput{}, nil + }, + } + + repo := &AwsRepository{IAMClient: mock} + err := repo.DeactivateAccessKey(context.Background(), "AKIAOLDKEY") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if deactivatedKeyID != "AKIAOLDKEY" { + t.Fatalf("expected AKIAOLDKEY to be deactivated, got %q", deactivatedKeyID) + } +} + +func TestDeactivateAccessKey_Error(t *testing.T) { + mock := &mockIAMClient{ + updateAccessKeyFunc: func(_ context.Context, _ *iam.UpdateAccessKeyInput, _ ...func(*iam.Options)) (*iam.UpdateAccessKeyOutput, error) { + return nil, fmt.Errorf("permission denied") + }, + } + + repo := &AwsRepository{IAMClient: mock} + err := repo.DeactivateAccessKey(context.Background(), "AKIAOLDKEY") + if err == nil { + t.Fatal("expected error for deactivation failure") + } +} + +func TestDeleteAccessKey_Success(t *testing.T) { + mock := &mockIAMClient{ + deleteAccessKeyFunc: func(_ context.Context, in *iam.DeleteAccessKeyInput, _ ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) { + if awssdk.ToString(in.AccessKeyId) != "AKIAOLDKEY" { + t.Fatalf("expected AKIAOLDKEY, got %q", awssdk.ToString(in.AccessKeyId)) + } + if in.UserName != nil { + t.Fatalf("expected current identity delete without UserName, got %#v", in.UserName) + } + return &iam.DeleteAccessKeyOutput{}, nil + }, + } + + repo := &AwsRepository{IAMClient: mock} + err := repo.DeleteAccessKey(context.Background(), "AKIAOLDKEY") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestVerifyAccessKey_RetriesTransientInvalidToken(t *testing.T) { + original := getCallerIdentityWithConfig + defer func() { + getCallerIdentityWithConfig = original + }() + + attempts := 0 + getCallerIdentityWithConfig = func(_ context.Context, _ awssdk.Config) (*sts.GetCallerIdentityOutput, error) { + attempts++ + if attempts < 3 { + return nil, fmt.Errorf("operation error STS: GetCallerIdentity, api error InvalidClientTokenId:") + } + return &sts.GetCallerIdentityOutput{ + Account: awssdk.String("123456789012"), + Arn: awssdk.String("arn:aws:iam::123456789012:user/test"), + UserId: awssdk.String("AIDATEST"), + }, nil + } + + repo := &AwsRepository{Region: "ap-northeast-2"} + identity, err := repo.VerifyAccessKey(context.Background(), &NewAccessKey{ + AccessKeyID: "AKIANEWKEY", + SecretAccessKey: "secret", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if attempts != 3 { + t.Fatalf("expected 3 attempts, got %d", attempts) + } + if identity.Account != "123456789012" { + t.Fatalf("expected account 123456789012, got %s", identity.Account) + } +} + +func TestVerifyAccessKey_DoesNotRetryNonRetryableError(t *testing.T) { + original := getCallerIdentityWithConfig + defer func() { + getCallerIdentityWithConfig = original + }() + + attempts := 0 + getCallerIdentityWithConfig = func(_ context.Context, _ awssdk.Config) (*sts.GetCallerIdentityOutput, error) { + attempts++ + return nil, fmt.Errorf("operation error STS: GetCallerIdentity, api error AccessDenied:") + } + + repo := &AwsRepository{Region: "ap-northeast-2"} + _, err := repo.VerifyAccessKey(context.Background(), &NewAccessKey{ + AccessKeyID: "AKIANEWKEY", + SecretAccessKey: "secret", + }) + if err == nil { + t.Fatal("expected error") + } + if attempts != 1 { + t.Fatalf("expected 1 attempt, got %d", attempts) + } +} + +// --- Model tests --- + +func TestAccessKeyAge(t *testing.T) { + key := AccessKey{ + CreateDate: time.Now().Add(-100 * 24 * time.Hour), + } + if key.Age() < 99 || key.Age() > 101 { + t.Errorf("expected age ~100, got %d", key.Age()) + } + if !key.IsAged() { + t.Error("key should be aged (>90 days)") + } +} + +func TestAccessKeyNotAged(t *testing.T) { + key := AccessKey{ + CreateDate: time.Now().Add(-30 * 24 * time.Hour), + } + if key.IsAged() { + t.Error("key should not be aged (<90 days)") + } +} + +func TestAccessKeyDisplayTitle(t *testing.T) { + key := AccessKey{ + AccessKeyID: "AKIATEST", + Status: "Active", + CreateDate: time.Now().Add(-10 * 24 * time.Hour), + LastUsed: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), + } + title := key.DisplayTitle() + if !containsIAM(title, "AKIATEST") { + t.Errorf("title should contain key ID, got %q", title) + } + if !containsIAM(title, "Active") { + t.Errorf("title should contain status, got %q", title) + } + if !containsIAM(title, "last:2025-06-01") { + t.Errorf("title should contain last used date, got %q", title) + } +} + +func TestAccessKeyFilterText(t *testing.T) { + key := AccessKey{ + AccessKeyID: "AKIATEST", + Status: "Active", + ServiceName: "s3", + } + ft := key.FilterText() + if !containsIAM(ft, "akiatest") { + t.Errorf("filter text should contain lowercase key ID, got %q", ft) + } +} + +func containsIAM(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsIAMStr(s, substr)) +} + +func containsIAMStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/services/aws/repository.go b/internal/services/aws/repository.go index 3055398..d71bce1 100644 --- a/internal/services/aws/repository.go +++ b/internal/services/aws/repository.go @@ -9,6 +9,7 @@ import ( awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/rds" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/aws/aws-sdk-go-v2/service/secretsmanager" @@ -33,6 +34,12 @@ var _ Route53ClientAPI = (*route53.Client)(nil) // Verify *secretsmanager.Client satisfies SecretsManagerClientAPI at compile time. var _ SecretsManagerClientAPI = (*secretsmanager.Client)(nil) +// Verify *iam.Client satisfies IAMClientAPI at compile time. +var _ IAMClientAPI = (*iam.Client)(nil) + +// Verify *sts.Client satisfies STSClientAPI at compile time. +var _ STSClientAPI = (*sts.Client)(nil) + // SSMClientAPI is the interface for SSM operations used by AwsRepository. type SSMClientAPI interface { ssm.DescribeInstanceInformationAPIClient @@ -63,6 +70,19 @@ type SecretsManagerClientAPI interface { GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) } +// IAMClientAPI is the interface for IAM operations used by AwsRepository. +type IAMClientAPI interface { + ListAccessKeys(ctx context.Context, params *iam.ListAccessKeysInput, optFns ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) + GetAccessKeyLastUsed(ctx context.Context, params *iam.GetAccessKeyLastUsedInput, optFns ...func(*iam.Options)) (*iam.GetAccessKeyLastUsedOutput, error) + CreateAccessKey(ctx context.Context, params *iam.CreateAccessKeyInput, optFns ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) + UpdateAccessKey(ctx context.Context, params *iam.UpdateAccessKeyInput, optFns ...func(*iam.Options)) (*iam.UpdateAccessKeyOutput, error) + DeleteAccessKey(ctx context.Context, params *iam.DeleteAccessKeyInput, optFns ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) +} + +type STSClientAPI interface { + GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) +} + // EC2ClientAPI is the interface for EC2 operations used by AwsRepository. type EC2ClientAPI interface { ec2.DescribeInstancesAPIClient @@ -78,14 +98,15 @@ type CallerIdentity struct { UserID string } -// AwsRepository holds AWS SDK clients for EC2, SSM, RDS, Route53, STS, and Secrets Manager. +// AwsRepository holds AWS SDK clients for EC2, SSM, RDS, Route53, STS, Secrets Manager, and IAM. type AwsRepository struct { EC2Client EC2ClientAPI SSMClient SSMClientAPI RDSClient RDSClientAPI Route53Client Route53ClientAPI SecretsManagerClient SecretsManagerClientAPI - STSClient *sts.Client + IAMClient IAMClientAPI + STSClient STSClientAPI Region string Profile string } @@ -150,6 +171,7 @@ func NewAwsRepository(ctx context.Context, cfg *config.Config) (*AwsRepository, RDSClient: rds.NewFromConfig(awsCfg), Route53Client: route53.NewFromConfig(awsCfg), SecretsManagerClient: secretsmanager.NewFromConfig(awsCfg), + IAMClient: iam.NewFromConfig(awsCfg), STSClient: sts.NewFromConfig(awsCfg), Region: cfg.Region, Profile: cfg.Profile,