From 924c25c9fd469462bd508d3aa0ac9787da015180 Mon Sep 17 00:00:00 2001 From: YoungJin Date: Mon, 3 Aug 2026 15:42:32 +0900 Subject: [PATCH] feat(sso): separate SSO portal region from resource region SSO contexts conflated the IAM Identity Center portal region with the resource query region into a single `region` field, so an SSO login in one region (e.g. us-east-1) forced all resource browsing into that same region even when resources live elsewhere (e.g. ap-northeast-2). Add an optional `sso_region` context field. SSO login, GetRoleCredentials, and account/role listing use `sso_region`; all resource clients use `region`. `sso_region` falls back to `region` when unset, so existing single-region configs are unchanged. Closes #231 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015fYsfxB3atyD47s3JqKYPT --- README.md | 12 +++++++ docs/architecture.en.md | 2 ++ docs/architecture.ko.md | 2 ++ internal/app/context_add.go | 4 ++- internal/auth/setup.go | 3 ++ internal/config/config.go | 20 +++++++++++- internal/config/config_test.go | 35 ++++++++++++++++++++ internal/services/aws/sso.go | 13 +++++--- internal/services/aws/sso_test.go | 54 +++++++++++++++++++++++++++++++ 9 files changed, 138 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ebb0fb0..fd9aafc 100644 --- a/README.md +++ b/README.md @@ -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 + - name: prod-admin order: 20 profile: base-profile @@ -198,6 +207,8 @@ 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: @@ -205,6 +216,7 @@ 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: diff --git a/docs/architecture.en.md b/docs/architecture.en.md index 4090c5f..8bda60b 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -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: diff --git a/docs/architecture.ko.md b/docs/architecture.ko.md index f5acbbe..a2c35cc 100644 --- a/docs/architecture.ko.md +++ b/docs/architecture.ko.md @@ -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 화면 계열 현재 화면 계열은 다음과 같다. diff --git a/internal/app/context_add.go b/internal/app/context_add.go index 2d29897..831c4ed 100644 --- a/internal/app/context_add.go +++ b/internal/app/context_add.go @@ -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}, @@ -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"], } diff --git a/internal/auth/setup.go b/internal/auth/setup.go index 3b64a88..2ab51f9 100644 --- a/internal/auth/setup.go +++ b/internal/auth/setup.go @@ -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 { @@ -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 @@ -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, } diff --git a/internal/config/config.go b/internal/config/config.go index f90a0fa..a75a6d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` } @@ -84,6 +85,7 @@ type Config struct { RoleArn string ExternalID string SSOStartURL string + SSORegion string SSOAccountID string SSORoleName string FavoriteServices []string @@ -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 "": @@ -119,6 +132,7 @@ type ContextInfo struct { RoleArn string ExternalID string SSOStartURL string + SSORegion string SSOAccountID string SSORoleName string Current bool @@ -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 { @@ -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 @@ -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), @@ -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), @@ -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, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a2536c1..96ac0fa 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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, ` diff --git a/internal/services/aws/sso.go b/internal/services/aws/sso.go index 959e8fd..adc2912 100644 --- a/internal/services/aws/sso.go +++ b/internal/services/aws/sso.go @@ -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) } @@ -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 } @@ -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) } @@ -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) } @@ -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, diff --git a/internal/services/aws/sso_test.go b/internal/services/aws/sso_test.go index 67579ca..3574a6a 100644 --- a/internal/services/aws/sso_test.go +++ b/internal/services/aws/sso_test.go @@ -7,6 +7,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "time" @@ -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)