Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ contexts:
sso_account_id: "123456789012"
sso_role_name: DeveloperRole

# SSO portal in one region, resources in another
- name: seoul-sso-123456789012-admin
region: ap-northeast-2 # where resources are queried
sso_region: us-east-1 # where the SSO/IAM Identity Center portal lives
auth_type: sso
sso_start_url: https://example.awsapps.com/start
sso_account_id: "123456789012"
sso_role_name: Admin

Comment on lines +174 to +181

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add the required profile field to the SSO example.

The example omits profile, but the sso required-fields table at Line [208] lists it as mandatory. Add the profile field so users can copy a valid concrete SSO context.

As per path instructions, README content must match implemented behavior and configuration content must stay aligned with code changes.

Proposed fix
   - name: seoul-sso-123456789012-admin
+    profile: my-sso-profile
     region: ap-northeast-2
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: seoul-sso-123456789012-admin
region: ap-northeast-2 # where resources are queried
sso_region: us-east-1 # where the SSO/IAM Identity Center portal lives
auth_type: sso
sso_start_url: https://example.awsapps.com/start
sso_account_id: "123456789012"
sso_role_name: Admin
- name: seoul-sso-123456789012-admin
profile: my-sso-profile
region: ap-northeast-2 # where resources are queried
sso_region: us-east-1 # where the SSO/IAM Identity Center portal lives
auth_type: sso
sso_start_url: https://example.awsapps.com/start
sso_account_id: "123456789012"
sso_role_name: Admin
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 174 - 181, Add the required profile field to the SSO
example configuration near the existing auth_type and SSO settings, using a
concrete profile value consistent with the example so it matches the mandatory
fields table and implemented configuration behavior.

Source: Path instructions

- name: prod-admin
order: 20
profile: base-profile
Expand Down Expand Up @@ -198,13 +207,16 @@ contexts:
| `assume_role` | Assume a role from a base profile | `profile`, `role_arn` |
| `sso` | Use AWS IAM Identity Center / SSO, reusing a valid AWS CLI SSO cache and prompting for login only when needed | `profile`, `sso_start_url`, and for concrete contexts `sso_account_id`, `sso_role_name` |

For `sso` contexts, `region` is the region resources are queried in. When the IAM Identity Center portal lives in a different region than your resources, set `sso_region` to the portal region — SSO login and role-credential retrieval use `sso_region`, while all resource browsing uses `region`. If `sso_region` is omitted, it defaults to `region` (backward compatible).

TUI startup is passive for SSO contexts: it loads the context picker without launching `aws sso login`. SSO login is prompted when you explicitly select or set up an SSO context, or when an AWS-backed workflow needs credentials.

Optional context fields:

| Field | Meaning |
|---|---|
| `order` | Lower values appear first in the context setup picker. Contexts without `order` fall back after ordered entries in their existing file order. |
| `sso_region` | (SSO only) Region of the IAM Identity Center portal, used for SSO login and role-credential retrieval. Defaults to `region` when unset. Use it when the SSO portal and your resources live in different regions. |

Resolution priority:

Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ Two shapes exist:
- includes `sso_account_id` and `sso_role_name`
- can produce direct environment exports and SDK credentials

`region` is the resource region; `sso_region` (optional) is the IAM Identity Center portal region used for SSO login and `GetRoleCredentials`. When `sso_region` is unset it falls back to `region`, so single-region setups need no change.

## TUI Screen Families

Current screen families include:
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ UNIC은 현재 세 가지 인증 모드를 지원한다.
- `sso_account_id`, `sso_role_name` 포함
- 직접 env export와 SDK credential 생성 가능

`region`은 리소스 조회 리전이고, `sso_region`(선택)은 SSO 로그인과 `GetRoleCredentials`에 쓰이는 IAM Identity Center 포털 리전이다. `sso_region`이 없으면 `region`으로 폴백하므로 단일 리전 설정은 변경할 필요가 없다.

## TUI 화면 계열

현재 화면 계열은 다음과 같다.
Expand Down
4 changes: 3 additions & 1 deletion internal/app/context_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ var fieldsByAuthType = map[string][]fieldDef{
"sso": {
{key: "name", label: "Name", required: true},
{key: "order", label: "Display Order (optional, lower first)", required: false},
{key: "region", label: "Region", required: true},
{key: "region", label: "Region (resources)", required: true},
{key: "sso_region", label: "SSO Login Region (optional, defaults to Region)", required: false},
{key: "sso_start_url", label: "SSO Start URL", required: true},
{key: "sso_account_id", label: "SSO Account ID", required: true},
{key: "sso_role_name", label: "SSO Role Name", required: true},
Expand Down Expand Up @@ -135,6 +136,7 @@ func (m Model) saveContext() tea.Cmd {
RoleArn: m.addValues["role_arn"],
ExternalID: m.addValues["external_id"],
SSOStartURL: m.addValues["sso_start_url"],
SSORegion: m.addValues["sso_region"],
SSOAccountID: m.addValues["sso_account_id"],
SSORoleName: m.addValues["sso_role_name"],
}
Expand Down
3 changes: 3 additions & 0 deletions internal/auth/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ func buildSSOContextEntry(configPath string, base config.ContextInfo, account aw
}
if ctx.Profile == base.Profile &&
ctx.Region == base.Region &&
ctx.SSORegion == base.SSORegion &&
ctx.SSOStartURL == base.SSOStartURL &&
ctx.SSOAccountID == account.ID &&
ctx.SSORoleName == role.Name {
Expand All @@ -327,6 +328,7 @@ func buildSSOContextEntry(configPath string, base config.ContextInfo, account aw
Region: ctx.Region,
AuthType: ctx.AuthType,
SSOStartURL: ctx.SSOStartURL,
SSORegion: ctx.SSORegion,
SSOAccountID: ctx.SSOAccountID,
SSORoleName: ctx.SSORoleName,
}, ctx.Name, nil
Expand All @@ -344,6 +346,7 @@ func buildSSOContextEntry(configPath string, base config.ContextInfo, account aw
Region: region,
AuthType: string(config.AuthTypeSSO),
SSOStartURL: base.SSOStartURL,
SSORegion: base.SSORegion,
SSOAccountID: account.ID,
SSORoleName: role.Name,
}
Expand Down
20 changes: 19 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type ContextEntry struct {
RoleArn string `yaml:"role_arn,omitempty"`
ExternalID string `yaml:"external_id,omitempty"`
SSOStartURL string `yaml:"sso_start_url,omitempty"`
SSORegion string `yaml:"sso_region,omitempty"`
SSOAccountID string `yaml:"sso_account_id,omitempty"`
SSORoleName string `yaml:"sso_role_name,omitempty"`
}
Expand All @@ -84,6 +85,7 @@ type Config struct {
RoleArn string
ExternalID string
SSOStartURL string
SSORegion string
SSOAccountID string
SSORoleName string
FavoriteServices []string
Expand All @@ -92,6 +94,17 @@ type Config struct {
BootSplashSeen string
}

// EffectiveSSORegion returns the region used for SSO/portal calls
// (GetRoleCredentials, ListAccounts, aws sso login). It falls back to the
// resource Region when sso_region is not set, preserving behavior for configs
// written before sso_region existed.
func (c *Config) EffectiveSSORegion() string {
if c.SSORegion != "" {
return c.SSORegion
}
return c.Region
}

func normalizeAuthType(value string) AuthType {
switch value {
case "":
Expand Down Expand Up @@ -119,6 +132,7 @@ type ContextInfo struct {
RoleArn string
ExternalID string
SSOStartURL string
SSORegion string
SSOAccountID string
SSORoleName string
Current bool
Expand Down Expand Up @@ -158,7 +172,7 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) {
}

// New format: resolve current context
var contextName, roleArn, externalID, ssoStartURL, ssoAccountID, ssoRoleName string
var contextName, roleArn, externalID, ssoStartURL, ssoRegion, ssoAccountID, ssoRoleName string
var authType AuthType
if fc.Current != "" {
for _, ctx := range fc.Contexts {
Expand All @@ -174,6 +188,7 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) {
roleArn = ctx.RoleArn
externalID = ctx.ExternalID
ssoStartURL = ctx.SSOStartURL
ssoRegion = ctx.SSORegion
ssoAccountID = ctx.SSOAccountID
ssoRoleName = ctx.SSORoleName
break
Expand Down Expand Up @@ -205,6 +220,7 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) {
RoleArn: roleArn,
ExternalID: externalID,
SSOStartURL: ssoStartURL,
SSORegion: ssoRegion,
SSOAccountID: ssoAccountID,
SSORoleName: ssoRoleName,
FavoriteServices: normalizeFavoriteServices(fc.Favorites.Services),
Expand Down Expand Up @@ -252,6 +268,7 @@ func LoadNamedContext(configPath, name string) (*Config, error) {
RoleArn: ctx.RoleArn,
ExternalID: ctx.ExternalID,
SSOStartURL: ctx.SSOStartURL,
SSORegion: ctx.SSORegion,
SSOAccountID: ctx.SSOAccountID,
SSORoleName: ctx.SSORoleName,
FavoriteServices: normalizeFavoriteServices(fc.Favorites.Services),
Expand Down Expand Up @@ -323,6 +340,7 @@ func Contexts(configPath string) ([]ContextInfo, error) {
RoleArn: ctx.RoleArn,
ExternalID: ctx.ExternalID,
SSOStartURL: ctx.SSOStartURL,
SSORegion: ctx.SSORegion,
SSOAccountID: ctx.SSOAccountID,
SSORoleName: ctx.SSORoleName,
Current: ctx.Name == fc.Current,
Expand Down
35 changes: 35 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,41 @@ ui:
}
}

func TestLoadReadsSSORegionSeparateFromRegion(t *testing.T) {
dir := t.TempDir()
path := writeUnicConfig(t, dir, `
current: my-sso
contexts:
- name: my-sso
auth_type: sso
region: ap-northeast-2
sso_region: us-east-1
sso_start_url: https://example.awsapps.com/start
sso_account_id: "123456789012"
sso_role_name: Admin
`)
cfg, err := Load(nil, nil, path)
if err != nil {
t.Fatal(err)
}
if cfg.Region != "ap-northeast-2" {
t.Fatalf("expected resource region ap-northeast-2, got %q", cfg.Region)
}
if cfg.SSORegion != "us-east-1" {
t.Fatalf("expected sso_region us-east-1, got %q", cfg.SSORegion)
}
if cfg.EffectiveSSORegion() != "us-east-1" {
t.Fatalf("expected effective SSO region us-east-1, got %q", cfg.EffectiveSSORegion())
}
}

func TestEffectiveSSORegionFallsBackToRegion(t *testing.T) {
cfg := &Config{Region: "ap-northeast-2"}
if got := cfg.EffectiveSSORegion(); got != "ap-northeast-2" {
t.Fatalf("expected fallback to region ap-northeast-2, got %q", got)
}
}

func TestSetBootSplashEnabledWritesConfig(t *testing.T) {
dir := t.TempDir()
path := writeUnicConfig(t, dir, `
Expand Down
13 changes: 8 additions & 5 deletions internal/services/aws/sso.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,9 @@ func resolveSSOCredentials(ctx context.Context, cfg *config.Config) (aws.Config,
return aws.Config{}, err
}

// Use the SSO token to get role credentials
baseCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(cfg.Region))
// The SSO portal (GetRoleCredentials) lives in the SSO region, which may
// differ from the region where the account's resources live.
baseCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(cfg.EffectiveSSORegion()))
if err != nil {
return aws.Config{}, fmt.Errorf("failed to load base AWS config: %w", err)
}
Expand All @@ -96,6 +97,8 @@ func resolveSSOCredentials(ctx context.Context, cfg *config.Config) (aws.Config,
aws.ToString(creds.SecretAccessKey),
aws.ToString(creds.SessionToken),
)
// Downstream resource clients must query the resource region, not the SSO region.
baseCfg.Region = cfg.Region

return baseCfg, nil
}
Expand Down Expand Up @@ -193,7 +196,7 @@ func ListSSOAccounts(ctx context.Context, cfg *config.Config) ([]SSOAccount, err
return nil, err
}

baseCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(cfg.Region))
baseCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(cfg.EffectiveSSORegion()))
if err != nil {
return nil, fmt.Errorf("failed to load base AWS config: %w", err)
}
Expand Down Expand Up @@ -234,7 +237,7 @@ func ListSSOAccountRoles(ctx context.Context, cfg *config.Config, accountID stri
return nil, err
}

baseCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(cfg.Region))
baseCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(cfg.EffectiveSSORegion()))
if err != nil {
return nil, fmt.Errorf("failed to load base AWS config: %w", err)
}
Expand Down Expand Up @@ -321,7 +324,7 @@ func BuildSSOLoginCmd(cfg *config.Config) (*exec.Cmd, func(), error) {
configContent := fmt.Sprintf("[profile %s]\nsso_start_url = %s\nsso_region = %s\nsso_account_id = %s\nsso_role_name = %s\nregion = %s\n",
profileName,
cfg.SSOStartURL,
cfg.Region,
cfg.EffectiveSSORegion(),
cfg.SSOAccountID,
cfg.SSORoleName,
cfg.Region,
Expand Down
54 changes: 54 additions & 0 deletions internal/services/aws/sso_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -143,6 +144,59 @@ func TestEnsureSSOLoginRunsLoginWhenTokenExpired(t *testing.T) {
}
}

func TestBuildSSOLoginCmdSeparatesSSORegionFromResourceRegion(t *testing.T) {
origWriteSSOConfigFile := writeSSOConfigFile
defer func() { writeSSOConfigFile = origWriteSSOConfigFile }()

var captured string
writeSSOConfigFile = func(_ string, data []byte, _ os.FileMode) error {
captured = string(data)
return nil
}

cfg := testSSOConfig()
cfg.Region = "ap-northeast-2"
cfg.SSORegion = "us-east-1"

_, cleanup, err := BuildSSOLoginCmd(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer cleanup()

if !strings.Contains(captured, "sso_region = us-east-1") {
t.Fatalf("expected sso_region = us-east-1 in generated config, got:\n%s", captured)
}
if !strings.Contains(captured, "region = ap-northeast-2") {
t.Fatalf("expected resource region = ap-northeast-2 in generated config, got:\n%s", captured)
}
}

func TestBuildSSOLoginCmdFallsBackToResourceRegionForSSO(t *testing.T) {
origWriteSSOConfigFile := writeSSOConfigFile
defer func() { writeSSOConfigFile = origWriteSSOConfigFile }()

var captured string
writeSSOConfigFile = func(_ string, data []byte, _ os.FileMode) error {
captured = string(data)
return nil
}

cfg := testSSOConfig()
cfg.Region = "ap-northeast-2"
cfg.SSORegion = "" // unset: should fall back to Region

_, cleanup, err := BuildSSOLoginCmd(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer cleanup()

if !strings.Contains(captured, "sso_region = ap-northeast-2") {
t.Fatalf("expected sso_region to fall back to ap-northeast-2, got:\n%s", captured)
}
}

func TestBuildSSOLoginCmdCleansTempDirOnConfigWriteError(t *testing.T) {
tempRoot := t.TempDir()
t.Setenv("TMPDIR", tempRoot)
Expand Down
Loading