Skip to content
Closed
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ unic context order

# Clear current context and copy cleanup commands to clipboard
unic context unset

# Generate contexts from the accounts/roles visible to an SSO base context
unic context sync
unic context sync dev-sso --dry-run
unic context sync dev-sso --prune
```

`unic context setup` writes its prompts to `stderr` and copies the generated shell commands to the clipboard.
Expand All @@ -119,6 +124,7 @@ Both flows now include a `UNIC_CONTEXT` marker in the generated exports so the T
Contexts can be prioritized in the setup picker with an `order` field in config.
In the CLI `unic context setup` flow, the picker filters contexts, SSO accounts, SSO roles, and configured resource regions as you type, with arrow-key navigation and Enter to confirm. Multi-region contexts prompt for the shell session region after account/role selection; single-region contexts skip that step. The selection changes `AWS_REGION` and `AWS_DEFAULT_REGION` in the generated exports without modifying the context's persisted default region.
Use `unic context order` to open reorder mode, choose a context with `↑/↓` or `j/k`, press `Enter` to start moving it, then press `Enter` again to save. `unic context order <name> <number>` still works for direct updates.
`unic context sync [base-context]` lists the AWS accounts and roles visible to an SSO base context and adds a sync-managed concrete context for each new account/role pair, inheriting the base context's regions. When only one SSO base context exists the argument can be omitted. Existing contexts are never rewritten: pairs that already have a context (manual or synced) are kept as-is. Synced contexts carry a `sync_source: <base-context>` marker in `config.yaml`; when their account/role disappears from SSO they are reported as orphans and removed only with `--prune`. Use `--dry-run` to preview the plan without writing config.

## Configuration

Expand Down Expand Up @@ -223,6 +229,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. |
| `sync_source` | Name of the SSO base context that generated this context via `unic context sync`. Marks the context as sync-managed: re-syncs may prune it (with `--prune`) when its account/role disappears from SSO. Contexts without this field are never touched by sync. |
| `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. |
| `resources.regions` / `regions` | Additional resource regions available through the global `R` picker. The default resource region is always included automatically. |

Expand Down
5 changes: 5 additions & 0 deletions docs/architecture.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ Owns non-TUI commands:
- live incremental filtering for large context/account/role lists
- interactive context ordering via `unic context order`
- can trigger `aws login` for `console_login` contexts
- `unic context sync [base-context]`
- generates a sync-managed context for every account/role pair visible to an SSO base context
- generated contexts carry a `sync_source` marker to stay distinguishable from manual ones
- sync-managed contexts whose account/role disappeared are reported as orphans and removed only with `--prune`
- `--dry-run` prints the plan without writing config
- `unic context unset`

### `internal/config/`
Expand Down
5 changes: 5 additions & 0 deletions docs/architecture.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ cmd/unic/main.go
- 많은 context/account/role 목록에서 live incremental filtering 지원
- `unic context order`를 통한 interactive context ordering 지원
- `console_login` context에서 `aws login` 실행 가능
- `unic context sync [base-context]`
- SSO base context에 보이는 account/role 조합마다 sync-managed context를 생성
- 생성된 context는 `sync_source` marker로 수동 context와 구분
- 사라진 account/role의 sync-managed context는 orphan으로 보고하고 `--prune`일 때만 삭제
- `--dry-run`으로 config를 쓰지 않고 plan만 출력
- `unic context unset`

### `internal/config/`
Expand Down
119 changes: 119 additions & 0 deletions internal/auth/sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package auth

import (
"context"
"fmt"
"sort"

"unic/internal/config"
)

// ContextSyncPlan describes what a sync run would change for one SSO base context.
type ContextSyncPlan struct {
Base string
// Add holds new sync-managed contexts for SSO account/role pairs that
// have no configured context yet.
Add []config.ContextEntry
// Keep lists configured contexts (manual or synced) that still match an
// SSO-visible account/role pair. Sync never rewrites them.
Keep []string
// Orphans lists sync-managed contexts for this base whose account/role
// pair is no longer visible in SSO.
Orphans []string
}

// BuildContextSyncPlan compares the accounts and roles visible to an SSO base
// context with the configured contexts and returns the additions, unchanged
// matches, and sync-managed orphans. It never plans changes to manually
// managed contexts.
func BuildContextSyncPlan(ctx context.Context, configPath string, base config.ContextInfo) (ContextSyncPlan, error) {
plan := ContextSyncPlan{Base: base.Name}

accounts, err := ListSSOContextAccounts(ctx, configPath, base)
if err != nil {
return plan, err
}

existing, err := config.Contexts(configPath)
if err != nil {
return plan, err
}

type roleKey struct{ account, role string }
configured := make(map[roleKey]config.ContextInfo)
for _, info := range existing {
if config.AuthType(info.AuthType) != config.AuthTypeSSO ||
info.SSOStartURL != base.SSOStartURL ||
info.SSOAccountID == "" || info.SSORoleName == "" {
continue
}
configured[roleKey{info.SSOAccountID, info.SSORoleName}] = info
}

region := base.Region
if region == "" {
region = config.DefaultRegion
}
resourceRegions := contextRegions(region, base.Regions)

desired := make(map[roleKey]bool)
names := append([]config.ContextInfo(nil), existing...)
for _, account := range accounts {
roles, err := ListSSOContextRoles(ctx, configPath, base, account.ID)
if err != nil {
return plan, err
}
for _, role := range roles {
key := roleKey{account.ID, role.Name}
desired[key] = true
if match, ok := configured[key]; ok {
plan.Keep = append(plan.Keep, match.Name)
continue
}
name := uniqueContextName(names, fmt.Sprintf("%s-%s-%s", base.Name, account.ID, sanitizeName(role.Name)))
names = append(names, config.ContextInfo{Name: name})
plan.Add = append(plan.Add, config.ContextEntry{
Name: name,
SyncSource: base.Name,
Auth: &config.ContextAuth{
Type: string(config.AuthTypeSSO),
Profile: base.Profile,
SSOStartURL: base.SSOStartURL,
SSORegion: base.SSORegion,
SSOAccountID: account.ID,
SSORoleName: role.Name,
},
Resources: &config.ContextResources{
DefaultRegion: region,
Regions: resourceRegions[1:],
},
})
}
}

for _, info := range existing {
if info.SyncSource != base.Name {
continue
}
if !desired[roleKey{info.SSOAccountID, info.SSORoleName}] {
plan.Orphans = append(plan.Orphans, info.Name)
}
}
sort.Strings(plan.Keep)
sort.Strings(plan.Orphans)
return plan, nil
}

// ApplyContextSyncPlan persists a sync plan: additions are upserted as
// sync-managed contexts, and orphans are removed only when prune is set.
func ApplyContextSyncPlan(configPath string, plan ContextSyncPlan, prune bool) error {
for _, entry := range plan.Add {
if err := config.UpsertContext(configPath, entry); err != nil {
return err
}
}
if prune {
return config.RemoveContexts(configPath, plan.Orphans)
}
return nil
}
174 changes: 174 additions & 0 deletions internal/auth/sync_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package auth

import (
"context"
"errors"
"testing"

"unic/internal/config"
awsservice "unic/internal/services/aws"
)

const syncBaseConfig = `current: dev-sso
contexts:
- name: dev-sso
profile: sso-profile
region: ap-northeast-2
auth_type: sso
sso_start_url: https://example.awsapps.com/start
- name: manual-admin
profile: sso-profile
region: ap-northeast-2
auth_type: sso
sso_start_url: https://example.awsapps.com/start
sso_account_id: "111111111111"
sso_role_name: Admin
- name: dev-sso-222222222222-stale
auth_type: sso
sso_start_url: https://example.awsapps.com/start
sso_account_id: "222222222222"
sso_role_name: Stale
region: ap-northeast-2
sync_source: dev-sso
`

func stubSSOListing(t *testing.T, accounts []awsservice.SSOAccount, roles map[string][]awsservice.SSORole) {
t.Helper()
origAccounts := listSSOAccountsFn
origRoles := listSSOAccountRolesFn
t.Cleanup(func() {
listSSOAccountsFn = origAccounts
listSSOAccountRolesFn = origRoles
})
listSSOAccountsFn = func(_ context.Context, _ *config.Config) ([]awsservice.SSOAccount, error) {
return accounts, nil
}
listSSOAccountRolesFn = func(_ context.Context, _ *config.Config, accountID string) ([]awsservice.SSORole, error) {
out, ok := roles[accountID]
if !ok {
return nil, errors.New("unexpected account " + accountID)
}
return out, nil
}
}

func syncBase(t *testing.T, configPath string) config.ContextInfo {
t.Helper()
contexts, err := config.Contexts(configPath)
if err != nil {
t.Fatalf("failed to list contexts: %v", err)
}
for _, ctx := range contexts {
if ctx.Name == "dev-sso" {
return ctx
}
}
t.Fatal("dev-sso base context not found")
return config.ContextInfo{}
}

func TestBuildContextSyncPlan(t *testing.T) {
configPath := writeConfig(t, t.TempDir(), syncBaseConfig)
stubSSOListing(t,
[]awsservice.SSOAccount{
{ID: "111111111111", Name: "prod"},
{ID: "333333333333", Name: "dev"},
},
map[string][]awsservice.SSORole{
"111111111111": {{Name: "Admin"}},
"333333333333": {{Name: "DeveloperRole"}},
},
)

plan, err := BuildContextSyncPlan(context.Background(), configPath, syncBase(t, configPath))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if len(plan.Add) != 1 || plan.Add[0].Name != "dev-sso-333333333333-developerrole" {
t.Fatalf("expected one added context for the new account/role, got %+v", plan.Add)
}
added := plan.Add[0]
if added.SyncSource != "dev-sso" {
t.Fatalf("expected added context to be marked sync-managed, got %+v", added)
}
if added.Auth == nil || added.Auth.SSOAccountID != "333333333333" || added.Auth.SSORoleName != "DeveloperRole" {
t.Fatalf("expected added context auth fields, got %+v", added.Auth)
}
if added.Resources == nil || added.Resources.DefaultRegion != "ap-northeast-2" {
t.Fatalf("expected added context to inherit the base region, got %+v", added.Resources)
}

if len(plan.Keep) != 1 || plan.Keep[0] != "manual-admin" {
t.Fatalf("expected the matching manual context to be kept, got %+v", plan.Keep)
}
if len(plan.Orphans) != 1 || plan.Orphans[0] != "dev-sso-222222222222-stale" {
t.Fatalf("expected the stale sync-managed context to be orphaned, got %+v", plan.Orphans)
}
}

func TestApplyContextSyncPlanWithoutPruneKeepsOrphans(t *testing.T) {
configPath := writeConfig(t, t.TempDir(), syncBaseConfig)
plan := ContextSyncPlan{
Base: "dev-sso",
Add: []config.ContextEntry{{
Name: "dev-sso-333333333333-developerrole",
SyncSource: "dev-sso",
Auth: &config.ContextAuth{
Type: "sso",
SSOStartURL: "https://example.awsapps.com/start",
SSOAccountID: "333333333333",
SSORoleName: "DeveloperRole",
},
Resources: &config.ContextResources{DefaultRegion: "ap-northeast-2"},
}},
Orphans: []string{"dev-sso-222222222222-stale"},
}

if err := ApplyContextSyncPlan(configPath, plan, false); err != nil {
t.Fatalf("unexpected error: %v", err)
}

contexts, err := config.Contexts(configPath)
if err != nil {
t.Fatalf("failed to list contexts: %v", err)
}
byName := map[string]config.ContextInfo{}
for _, ctx := range contexts {
byName[ctx.Name] = ctx
}
added, ok := byName["dev-sso-333333333333-developerrole"]
if !ok || added.SyncSource != "dev-sso" {
t.Fatalf("expected sync-managed context to be persisted, got %+v", byName)
}
if _, ok := byName["dev-sso-222222222222-stale"]; !ok {
t.Fatal("expected orphan to survive without --prune")
}
}

func TestApplyContextSyncPlanPruneRemovesOnlyOrphans(t *testing.T) {
configPath := writeConfig(t, t.TempDir(), syncBaseConfig)
plan := ContextSyncPlan{
Base: "dev-sso",
Orphans: []string{"dev-sso-222222222222-stale"},
}

if err := ApplyContextSyncPlan(configPath, plan, true); err != nil {
t.Fatalf("unexpected error: %v", err)
}

contexts, err := config.Contexts(configPath)
if err != nil {
t.Fatalf("failed to list contexts: %v", err)
}
names := map[string]bool{}
for _, ctx := range contexts {
names[ctx.Name] = true
}
if names["dev-sso-222222222222-stale"] {
t.Fatal("expected orphan to be pruned")
}
if !names["dev-sso"] || !names["manual-admin"] {
t.Fatalf("expected base and manual contexts to survive prune, got %+v", names)
}
}
Loading
Loading