diff --git a/CLAUDE.md b/CLAUDE.md index 7697ad6..a99ee9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,11 @@ cmd/ status.go Project status (costs, ECS services) whoami.go Show AWS + GitHub identity secret.go List/set app.yaml secrets in SSM + aws.go aws setup / aws doctor (SSO config for javaBin) internal/ aws/aws.go AWS SDK helpers (STS, Cost Explorer, ECS) + aws/profile.go --profile and team-profile auto-discovery + awsconfig/ Edits ~/.aws/config preserving unrelated content; SSO token cache + ListAccountRoles config/config.go Config directory (~/.javabin/) github/github.go GitHub API client (token, branches, PRs) ``` @@ -28,7 +31,7 @@ internal/ ## Authentication - **GitHub**: Uses `gh auth token` (gh CLI) first, then falls back to `GITHUB_TOKEN` / `GH_TOKEN` environment variables -- **AWS**: Standard credential chain via `aws-sdk-go-v2/config` (env vars, `~/.aws/credentials`, SSO). Region defaults to `eu-central-1`. +- **AWS**: Standard credential chain via `aws-sdk-go-v2/config` (env vars, `~/.aws/credentials`, SSO). Region defaults to `eu-central-1`. Team commands resolve `--profile`, then `AWS_PROFILE`, then the single profile with `sso_role_name = javabin-team-` (written by `javabin aws setup`); without one the SDK would hang on EC2 IMDS, so `credentialsHelp` points at `javabin aws setup`. - **Cognito**: TODO — will add device flow auth against internal Cognito pool when Identity (Phase 1) is implemented. Token will be cached at `~/.javabin/token`. ## Commands @@ -39,6 +42,8 @@ internal/ | `javabin init` | Interactive wizard — scaffolds a new app repo from `javaBin/app-template`, writes `app.yaml`, `Dockerfile`, and deploy workflow, then prints next steps for adding the repo to a GitHub team | | `javabin status` | Shows month-to-date cost (Cost Explorer) and ECS service status. Infers project from git remote or accepts `--project` flag | | `javabin secret list` / `javabin secret set [--stdin]` | Run in an app repo: shows which `resources.secrets` from `app.yaml` still hold the platform's `PLACEHOLDER` (never prints values) and overwrites an existing value as an SSM SecureString under `/javabin/apps/{team}/{app}/` (never creates parameters — deploy first). The platform restarts the service automatically when a secret changes. Needs the `javabin-developer` SSO role | +| `javabin aws setup [--dry-run] [--no-login] [--force]` | Adds `[sso-session javabin]` (Identity Center start URL, `eu-central-1`) to `AWS_CONFIG_FILE`/`~/.aws/config`, runs `aws sso login --sso-session javabin`, lists the user's roles in account 553637109631 (SSO ListAccountRoles with the cached token) and writes one `[profile ]` per role (`javabin-team-`, `javabin-developer`, ...; non-`javabin-` roles get a `javabin-` prefix). Never edits/deletes existing profiles (conflicts are reported), skips roles another `sso_session = javabin` profile already uses (profiles on other sessions are kept but warned about, as is a role with several profiles, which turns off team-profile auto-discovery), backs up to `.bak-` (symlinks are followed and kept), writes atomically with 0600. A session pointing at another start URL stops unless `--force` | +| `javabin aws doctor [--app-yaml]` | Checks the session block, SSO token expiry, and which profile `secret` would pick for `app.yaml`'s team | | `javabin whoami` | Shows AWS identity (STS GetCallerIdentity) and GitHub user (gh API) | ## Build and Test diff --git a/README.md b/README.md index 71e5fb1..fd31bac 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,19 @@ javabin status # infers project from git remote javabin status --project moresleep ``` +### `javabin aws setup` + +One-time AWS setup — no hand-editing of `~/.aws/config`. Requires the [AWS CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) (`brew install awscli`). + +```bash +javabin aws setup # add SSO session, sign in, write a profile per role you have +javabin aws setup --dry-run # show what would change +javabin aws setup --no-login # reuse a valid SSO session +javabin aws doctor # check config, token and the profile `secret` would use +``` + +It adds an `[sso-session javabin]` block for javaBin's IAM Identity Center, runs `aws sso login --sso-session javabin`, then writes one profile per role you are assigned in account `553637109631`. Profiles are named after the role: `javabin-team-`, `javabin-developer`, `javabin-admin`, `javabin-readonly`. Existing profiles are never changed; the config is backed up to `config.bak-` first. `javabin secret ...` picks your team's profile automatically; elsewhere use `--profile javabin-team-` or `AWS_PROFILE`. Sessions expire after ~8h: `aws sso login --sso-session javabin`. + ### `javabin whoami` Show current identity (AWS + GitHub). @@ -53,7 +66,7 @@ javabin whoami ## Authentication - **GitHub:** Uses `gh auth token` if available, or `GITHUB_TOKEN`/`GH_TOKEN` environment variables -- **AWS:** Standard credential chain (environment variables, `~/.aws/credentials`, SSO) +- **AWS:** Standard credential chain (environment variables, `~/.aws/credentials`, SSO). Run `javabin aws setup` once to get SSO profiles. ## What This CLI Does NOT Do diff --git a/cmd/aws.go b/cmd/aws.go new file mode 100644 index 0000000..89718af --- /dev/null +++ b/cmd/aws.go @@ -0,0 +1,320 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + jbaws "github.com/javaBin/javabin-cli/internal/aws" + "github.com/javaBin/javabin-cli/internal/awsconfig" +) + +var ( + awsSetupForce bool + awsSetupNoLogin bool + awsSetupDryRun bool + awsDoctorYaml string +) + +// awsDeps is everything `aws setup` touches outside the config file, so +// tests can swap in fakes. +type awsDeps struct { + configPath string + cacheDir string + lookAws func() error // checks the AWS CLI is installed + login func() error + roles awsconfig.RoleLister + now func() time.Time + out io.Writer +} + +func defaultAwsDeps() awsDeps { + return awsDeps{ + configPath: awsconfig.DefaultPath(), + cacheDir: awsconfig.DefaultCacheDir(), + lookAws: func() error { _, err := findAws(); return err }, + login: ssoLogin, + roles: awsconfig.SSORoleLister{Region: awsconfig.SSORegion}, + now: time.Now, + out: os.Stdout, + } +} + +var awsCmd = &cobra.Command{ + Use: "aws", + Short: "Set up and check AWS SSO access to the javaBin account", + // errors here are actionable messages, not usage mistakes + PersistentPreRun: func(cmd *cobra.Command, args []string) { cmd.SilenceUsage = true }, +} + +var awsSetupCmd = &cobra.Command{ + Use: "setup", + Short: "Write the javaBin SSO session and one profile per role you have to ~/.aws/config", + Long: `Adds an [sso-session javabin] block for javaBin's IAM Identity Center to the +AWS config file (AWS_CONFIG_FILE or ~/.aws/config), signs in with +'aws sso login --sso-session javabin', lists the roles you are assigned in +account ` + awsconfig.AccountID + ` and writes one profile per role. + +Profiles are named after the role: javabin-team-, javabin-developer, +javabin-admin, ... (a role without the javabin- prefix gets one). Existing +profiles are never changed or deleted; the file is backed up to +.bak- before writing.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAwsSetup(cmd.Context(), defaultAwsDeps(), awsSetupForce, awsSetupNoLogin, awsSetupDryRun) + }, +} + +func runAwsSetup(ctx context.Context, d awsDeps, force, noLogin, dryRun bool) error { + if ctx == nil { + ctx = context.Background() + } + orig, err := readConfig(d.configPath) + if err != nil { + return err + } + + needLogin := !noLogin && !dryRun + if needLogin { // fail before touching the config + if err := d.lookAws(); err != nil { + return err + } + } + + // 1. The sso-session block must be on disk before aws sso login can use it. + withSession, r, err := awsconfig.Apply(orig, nil, force) + if err != nil { + return err + } + backedUp := false + if r.SessionChanged { + if dryRun { + fmt.Fprintf(d.out, "Would add/update [%s] in %s\n", awsconfig.SessionSection, d.configPath) + } else { + bak, err := awsconfig.WriteFile(d.configPath, withSession, true, d.now()) + if err != nil { + return err + } + backedUp = true + fmt.Fprintf(d.out, "Wrote [%s] to %s\n", awsconfig.SessionSection, d.configPath) + if bak != "" { + fmt.Fprintf(d.out, "Backup: %s\n", bak) + } + } + } + + // 2. Sign in (writes only the AWS CLI's token cache). + if needLogin { + if err := d.login(); err != nil { + return err + } + } + + // 3. Roles this user actually has. + tok, err := awsconfig.ReadToken(d.cacheDir, awsconfig.SessionName, d.now()) + if err != nil { + if dryRun && errors.Is(err, awsconfig.ErrNoToken) { + fmt.Fprintf(d.out, "Not signed in (%v); profiles are listed after 'aws sso login --sso-session %s'.\n", err, awsconfig.SessionName) + if r.SessionChanged { + printDiff(d.out, orig, withSession) + } + return nil + } + return fmt.Errorf("%w — run: aws sso login --sso-session %s", err, awsconfig.SessionName) + } + roles, err := d.roles.ListRoles(ctx, tok.AccessToken, awsconfig.AccountID) + if err != nil { + return fmt.Errorf("list your roles in account %s: %w", awsconfig.AccountID, err) + } + if len(roles) == 0 { + return fmt.Errorf("you have no roles in account %s — ask a javaBin admin to add you to your team", awsconfig.AccountID) + } + + // 4. Profiles. + final, pr, err := awsconfig.Apply(withSession, roles, force) + if err != nil { + return err + } + if dryRun { + if final == orig { + fmt.Fprintln(d.out, "No changes.") + } else { + printDiff(d.out, orig, final) + } + } else if final != withSession { + bak, err := awsconfig.WriteFile(d.configPath, final, !backedUp, d.now()) + if err != nil { + return err + } + if bak != "" { + fmt.Fprintf(d.out, "Backup: %s\n", bak) + } + } + printSetupSummary(d.out, roles, pr, dryRun) + return nil +} + +func readConfig(path string) (string, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", nil + } + return string(data), err +} + +// printDiff shows the lines added (new content is always appended or +// inserted, never removed). +func printDiff(w io.Writer, before, after string) { + old := map[string]int{} + for _, l := range strings.Split(before, "\n") { + old[l]++ + } + fmt.Fprintln(w, "Changes:") + for _, l := range strings.Split(strings.TrimRight(after, "\n"), "\n") { + if old[l] > 0 { + old[l]-- + continue + } + fmt.Fprintln(w, "+ "+l) + } +} + +func printSetupSummary(w io.Writer, roles []string, r *awsconfig.Result, dryRun bool) { + verb := "Added" + if dryRun { + verb = "Would add" + } + if len(r.Added) > 0 { + fmt.Fprintf(w, "%s profiles: %s\n", verb, strings.Join(r.Added, ", ")) + } + if len(r.Unchanged) > 0 { + fmt.Fprintf(w, "Already up to date: %s\n", strings.Join(r.Unchanged, ", ")) + } + for _, role := range r.Conflicts { + fmt.Fprintf(w, "Left alone: [profile %s] exists with different settings — fix or remove it yourself if it should point at role %s\n", awsconfig.ProfileName(role), role) + } + sort.Strings(roles) + for _, role := range roles { + for _, ref := range r.OtherSession[role] { + how := "sso_session = " + ref.Session + if ref.Session == "" { + how = "no sso_session (legacy sso_start_url)" + } + fmt.Fprintf(w, "Warning: existing [profile %s] also uses role %s but with %s; `aws sso login --sso-session %s` does not refresh it — consider removing it\n", + ref.Profile, role, how, awsconfig.SessionName) + } + if refs, ok := r.Ambiguous[role]; ok { + use := awsconfig.ProfileName(role) + if c, ok := r.Covered[role]; ok { + use = c + } + if team, isTeam := strings.CutPrefix(role, "javabin-team-"); isTeam { + fmt.Fprintf(w, "Warning: %d profiles use role %s (%s), so `javabin secret` cannot pick one for team %s — pass --profile %s (or set AWS_PROFILE), or remove the others\n", + len(refs), role, strings.Join(refs, ", "), team, use) + } + } + } + var teams []string + for _, role := range roles { + profile := awsconfig.ProfileName(role) + if other, ok := r.Covered[role]; ok { + profile = other + fmt.Fprintf(w, "Role %s already has profile %s; not adding another\n", role, other) + } + if team, ok := strings.CutPrefix(role, "javabin-team-"); ok { + teams = append(teams, fmt.Sprintf(" team %-20s --profile %s", team, profile)) + } + } + if len(teams) > 0 { + fmt.Fprintln(w, "\nProfiles per team:") + for _, t := range teams { + fmt.Fprintln(w, t) + } + if len(r.Ambiguous) == 0 { + fmt.Fprintln(w, "\n`javabin secret ...` picks the team profile from app.yaml automatically.") + } + } + fmt.Fprintf(w, "Sessions last about 8 hours; renew with: aws sso login --sso-session %s\n", awsconfig.SessionName) +} + +func findAws() (string, error) { + bin, err := exec.LookPath("aws") + if err != nil { + return "", errors.New("the AWS CLI (v2) is not installed. Install it, then rerun javabin aws setup:\n" + + " macOS: brew install awscli\n" + + " other: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html") + } + return bin, nil +} + +func ssoLogin() error { + bin, err := findAws() + if err != nil { + return err + } + c := exec.Command(bin, "sso", "login", "--sso-session", awsconfig.SessionName) + c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("aws sso login: %w", err) + } + return nil +} + +var awsDoctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Check the javaBin SSO config, token and which profile `secret` would use", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAwsDoctor(defaultAwsDeps(), awsDoctorYaml) + }, +} + +func runAwsDoctor(d awsDeps, appYaml string) error { + ok := true + report := func(good bool, format string, a ...any) { + mark := "ok " + if !good { + mark, ok = "FAIL", false + } + fmt.Fprintf(d.out, "[%s] %s\n", mark, fmt.Sprintf(format, a...)) + } + data, err := readConfig(d.configPath) + if err != nil { + return err + } + f := awsconfig.Parse(data) + sess := f.Values(awsconfig.SessionSection) + report(sess != nil && awsconfig.SameStartURL(sess["sso_start_url"]), "%s has [%s] for %s", d.configPath, awsconfig.SessionSection, awsconfig.StartURL) + if tok, err := awsconfig.ReadToken(d.cacheDir, awsconfig.SessionName, d.now()); err != nil { + report(false, "SSO token: %v (run: aws sso login --sso-session %s)", err, awsconfig.SessionName) + } else { + report(true, "SSO token valid until %s", tok.ExpiresAt.Local().Format("15:04 Mon 2 Jan")) + } + if app, err := loadAppYaml(appYaml); err == nil { + p := jbaws.FindTeamProfile(d.configPath, app.Team) + report(p != "", "%s (team %s): javabin secret would use profile %q", appYaml, app.Team, p) + } else { + fmt.Fprintf(d.out, "[skip] no %s here — team profile not checked\n", appYaml) + } + if !ok { + return errors.New("problems found — run: javabin aws setup") + } + return nil +} + +func init() { + awsSetupCmd.Flags().BoolVar(&awsSetupForce, "force", false, "replace the settings of an existing [sso-session javabin] that points elsewhere") + awsSetupCmd.Flags().BoolVar(&awsSetupNoLogin, "no-login", false, "skip aws sso login (use the cached token)") + awsSetupCmd.Flags().BoolVar(&awsSetupDryRun, "dry-run", false, "print the changes without writing or signing in") + awsDoctorCmd.Flags().StringVar(&awsDoctorYaml, "app-yaml", "app.yaml", "path to app.yaml") + awsCmd.AddCommand(awsSetupCmd, awsDoctorCmd) + rootCmd.AddCommand(awsCmd) +} diff --git a/cmd/aws_test.go b/cmd/aws_test.go new file mode 100644 index 0000000..8836c05 --- /dev/null +++ b/cmd/aws_test.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + jbaws "github.com/javaBin/javabin-cli/internal/aws" +) + +type fakeRoles struct { + roles []string + token string +} + +func (f *fakeRoles) ListRoles(_ context.Context, token, _ string) ([]string, error) { + f.token = token + return f.roles, nil +} + +func testDeps(t *testing.T) (awsDeps, *bytes.Buffer, *int) { + dir := t.TempDir() + cache := filepath.Join(dir, "cache") + _ = os.MkdirAll(cache, 0o700) + logins := 0 + out := &bytes.Buffer{} + now := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC) + return awsDeps{ + configPath: filepath.Join(dir, ".aws", "config"), + cacheDir: cache, + lookAws: func() error { return nil }, + login: func() error { + logins++ + sum := sha1.Sum([]byte("javabin")) + return os.WriteFile(filepath.Join(cache, hex.EncodeToString(sum[:])+".json"), + []byte(`{"accessToken":"tok","expiresAt":"2026-10-24T20:00:00Z"}`), 0o600) + }, + roles: &fakeRoles{roles: []string{"javabin-team-cupcake", "javabin-developer"}}, + now: func() time.Time { return now }, + out: out, + }, out, &logins +} + +func TestAwsSetupEndToEnd(t *testing.T) { + d, out, logins := testDeps(t) + _ = os.MkdirAll(filepath.Dir(d.configPath), 0o700) + _ = os.WriteFile(d.configPath, []byte("[profile mine]\nregion = us-east-1\n"), 0o644) + + if err := runAwsSetup(context.Background(), d, false, false, false); err != nil { + t.Fatal(err) + } + if *logins != 1 || d.roles.(*fakeRoles).token != "tok" { + t.Errorf("logins=%d token=%q", *logins, d.roles.(*fakeRoles).token) + } + data, _ := os.ReadFile(d.configPath) + if !strings.HasPrefix(string(data), "[profile mine]\nregion = us-east-1\n") { + t.Errorf("existing profile changed:\n%s", data) + } + if got := jbaws.FindTeamProfile(d.configPath, "cupcake"); got != "javabin-team-cupcake" { + t.Errorf("secret would pick %q", got) + } + baks, _ := filepath.Glob(d.configPath + ".bak-*") + if len(baks) != 1 { + t.Errorf("want exactly one backup, got %v", baks) + } + if st, _ := os.Stat(d.configPath); st.Mode().Perm() != 0o600 { + t.Errorf("mode %v", st.Mode()) + } + if !strings.Contains(out.String(), "team cupcake") { + t.Errorf("summary:\n%s", out) + } + + // second run: nothing to write + before := string(data) + if err := runAwsSetup(context.Background(), d, false, true, false); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(d.configPath) + if string(after) != before { + t.Error("second run changed the file") + } +} + +func TestAwsSetupDryRunWritesNothing(t *testing.T) { + d, out, logins := testDeps(t) + if err := runAwsSetup(context.Background(), d, false, false, true); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(d.configPath); !os.IsNotExist(err) || *logins != 0 { + t.Errorf("dry run wrote config or logged in (logins=%d)", *logins) + } + if !strings.Contains(out.String(), "+ [sso-session javabin]") { + t.Errorf("output:\n%s", out) + } +} + +func TestAwsSetupSessionConflictStops(t *testing.T) { + d, _, logins := testDeps(t) + _ = os.MkdirAll(filepath.Dir(d.configPath), 0o700) + orig := "[sso-session javabin]\nsso_start_url = https://other.awsapps.com/start\n" + _ = os.WriteFile(d.configPath, []byte(orig), 0o600) + if err := runAwsSetup(context.Background(), d, false, false, false); err == nil || !strings.Contains(err.Error(), "--force") { + t.Fatalf("want conflict error, got %v", err) + } + if data, _ := os.ReadFile(d.configPath); string(data) != orig || *logins != 0 { + t.Error("conflict must not write or log in") + } +} + +func TestAwsSetupNoAwsCLIWritesNothing(t *testing.T) { + d, _, logins := testDeps(t) + d.lookAws = func() error { return errors.New("the AWS CLI (v2) is not installed") } + if err := runAwsSetup(context.Background(), d, false, false, false); err == nil || !strings.Contains(err.Error(), "not installed") { + t.Fatalf("want install error, got %v", err) + } + if _, err := os.Stat(d.configPath); !os.IsNotExist(err) || *logins != 0 { + t.Error("must not write config or log in without the AWS CLI") + } + // --no-login does not need the CLI + if err := runAwsSetup(context.Background(), d, false, true, false); err == nil || strings.Contains(err.Error(), "not installed") { + t.Fatalf("--no-login should fail only on the missing token, got %v", err) + } +} + +func TestAwsSetupWarnsAboutOtherSessionProfile(t *testing.T) { + d, out, _ := testDeps(t) + _ = os.MkdirAll(filepath.Dir(d.configPath), 0o700) + _ = os.WriteFile(d.configPath, []byte("[profile cupcake]\nsso_session = jb\nsso_account_id = 553637109631\nsso_role_name = javabin-team-cupcake\n"), 0o600) + if err := runAwsSetup(context.Background(), d, false, false, false); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(d.configPath) + if !strings.Contains(string(data), "[profile javabin-team-cupcake]") { + t.Errorf("javabin-session profile not added:\n%s", data) + } + o := out.String() + for _, want := range []string{"[profile cupcake] also uses role javabin-team-cupcake but with sso_session = jb", "cannot pick one for team cupcake", "--profile javabin-team-cupcake"} { + if !strings.Contains(o, want) { + t.Errorf("output missing %q:\n%s", want, o) + } + } + if strings.Contains(o, "picks the team profile from app.yaml automatically") { + t.Error("must not promise auto-discovery when it is off") + } +} + +func TestAwsSetupConflictNamesRole(t *testing.T) { + d, out, _ := testDeps(t) + d.roles = &fakeRoles{roles: []string{"ReadOnly"}} + _ = os.MkdirAll(filepath.Dir(d.configPath), 0o700) + _ = os.WriteFile(d.configPath, []byte("[profile javabin-ReadOnly]\nregion = us-east-1\n"), 0o600) + if err := runAwsSetup(context.Background(), d, false, false, false); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "[profile javabin-ReadOnly] exists with different settings — fix or remove it yourself if it should point at role ReadOnly") { + t.Errorf("output:\n%s", out) + } +} + +func TestAwsDoctorToleratesTrailingSlash(t *testing.T) { + d, out, _ := testDeps(t) + _ = os.MkdirAll(filepath.Dir(d.configPath), 0o700) + _ = os.WriteFile(d.configPath, []byte("[sso-session javabin]\nsso_start_url = https://identitycenter.amazonaws.com/ssoins-6987654c95c9a069/#\n"), 0o600) + _ = d.login() // writes a valid token + if err := runAwsDoctor(d, filepath.Join(t.TempDir(), "app.yaml")); err != nil { + t.Fatalf("%v\n%s", err, out) + } +} diff --git a/go.mod b/go.mod index 90705c7..4faf539 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/costexplorer v1.45.1 github.com/aws/aws-sdk-go-v2/service/ecs v1.52.1 github.com/aws/aws-sdk-go-v2/service/ssm v1.81.0 + github.com/aws/aws-sdk-go-v2/service/sso v1.24.8 github.com/aws/aws-sdk-go-v2/service/sts v1.33.3 github.com/spf13/cobra v1.8.1 golang.org/x/term v0.47.0 @@ -23,7 +24,6 @@ require ( github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.24.8 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7 // indirect github.com/aws/smithy-go v1.28.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/internal/aws/profile.go b/internal/aws/profile.go index cfa343e..3b8686e 100644 --- a/internal/aws/profile.go +++ b/internal/aws/profile.go @@ -51,10 +51,9 @@ func credentialsHelp(err error, profile, team string) error { if profile != "" { return fmt.Errorf("no valid AWS credentials for profile %q — run: aws sso login --profile %s\n(%w)", profile, profile, err) } - return errors.New("no AWS credentials found. Sign in with your team's SSO role (" + role + "):\n" + - " aws configure sso # once: account " + AccountID + ", role " + role + "\n" + - " aws sso login --profile \n" + - "then pass --profile or set AWS_PROFILE=") + return errors.New("no AWS credentials found. Set up javaBin SSO profiles (once) and sign in:\n" + + " javabin aws setup # writes ~/.aws/config profiles for your roles, e.g. " + role + "\n" + + "then rerun; the team profile is picked automatically (or pass --profile " + role + ")") } func awsConfigPath() string { diff --git a/internal/awsconfig/awsconfig.go b/internal/awsconfig/awsconfig.go new file mode 100644 index 0000000..e1be568 --- /dev/null +++ b/internal/awsconfig/awsconfig.go @@ -0,0 +1,263 @@ +// Package awsconfig reads and edits the AWS CLI config file (~/.aws/config) +// without disturbing content it does not own: unrelated sections, comments, +// blank lines and ordering are written back byte-for-byte. +package awsconfig + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// DefaultPath is AWS_CONFIG_FILE, else ~/.aws/config. +func DefaultPath() string { + if p := os.Getenv("AWS_CONFIG_FILE"); p != "" { + return p + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".aws", "config") +} + +// KV is one key = value setting, kept ordered so new sections render stably. +type KV struct{ Key, Value string } + +type section struct { + name string // header content, e.g. "profile foo", "sso-session javabin", "default" + lines []string // raw lines including the header (empty name: preamble, no header) +} + +// File is a parsed config file. Only the sections touched through Set / +// AddSection change when rendered. +type File struct { + sections []*section + trailNL bool +} + +// Parse splits data into sections. It never fails: lines it does not +// understand are kept verbatim. +func Parse(data string) *File { + f := &File{trailNL: data == "" || strings.HasSuffix(data, "\n")} + cur := §ion{} + f.sections = append(f.sections, cur) + if data == "" { + return f + } + for _, line := range strings.Split(strings.TrimSuffix(data, "\n"), "\n") { + if name, ok := parseHeader(line); ok { + cur = §ion{name: name, lines: []string{line}} + f.sections = append(f.sections, cur) + continue + } + cur.lines = append(cur.lines, line) + } + return f +} + +// parseHeader recognises "[name]", optionally followed by a # or ; comment. +func parseHeader(line string) (string, bool) { + t := strings.TrimSpace(line) + if !strings.HasPrefix(t, "[") { + return "", false + } + end := strings.Index(t, "]") + if end < 0 { + return "", false + } + if rest := strings.TrimSpace(t[end+1:]); rest != "" && rest[0] != '#' && rest[0] != ';' { + return "", false + } + return strings.Join(strings.Fields(t[1:end]), " "), true +} + +// String renders the file. +func (f *File) String() string { + var all []string + for _, s := range f.sections { + all = append(all, s.lines...) + } + out := strings.Join(all, "\n") + if out != "" && f.trailNL { + out += "\n" + } + return out +} + +func (f *File) find(name string) *section { + for _, s := range f.sections[1:] { + if s.name == name { + return s + } + } + return nil +} + +// Has reports whether a section with this header exists. +func (f *File) Has(name string) bool { return f.find(name) != nil } + +// Values returns a section's top-level key/values (nested sub-properties and +// comments are ignored), or nil if the section does not exist. +func (f *File) Values(name string) map[string]string { + s := f.find(name) + if s == nil { + return nil + } + vals := map[string]string{} + for _, line := range s.lines[1:] { + if k, v, ok := parseKV(line); ok { + vals[k] = v + } + } + return vals +} + +// SectionNames lists section headers in file order. +func (f *File) SectionNames() []string { + var names []string + for _, s := range f.sections[1:] { + names = append(names, s.name) + } + return names +} + +func parseKV(line string) (string, string, bool) { + if line == "" || line[0] == ' ' || line[0] == '\t' { // nested sub-property + return "", "", false + } + t := strings.TrimSpace(line) + if t == "" || t[0] == '#' || t[0] == ';' { + return "", "", false + } + k, v, ok := strings.Cut(t, "=") + if !ok { + return "", "", false + } + if i := strings.Index(v, " #"); i >= 0 { + v = v[:i] + } + if i := strings.Index(v, " ;"); i >= 0 { + v = v[:i] + } + return strings.TrimSpace(k), strings.TrimSpace(v), true +} + +// Set updates key in an existing section in place, or appends it after the +// section's last setting. It reports whether anything changed. +func (f *File) Set(name, key, value string) bool { + s := f.find(name) + if s == nil { + return false + } + last, inKV := 0, false + for i, line := range s.lines[1:] { + k, v, ok := parseKV(line) + if !ok { + // keep a new key out of a nested block such as "s3 =\n max_... = 20" + if inKV && strings.TrimSpace(line) != "" && (line[0] == ' ' || line[0] == '\t') { + last = i + 1 + } else if strings.TrimSpace(line) == "" { + inKV = false + } + continue + } + last, inKV = i+1, true + if k == key { + if v == value { + return false + } + s.lines[i+1] = key + " = " + value + return true + } + } + s.lines = append(s.lines[:last+1], append([]string{key + " = " + value}, s.lines[last+1:]...)...) + return true +} + +// AddSection appends a new section at the end of the file, separated by a +// blank line. +func (f *File) AddSection(name string, kvs []KV) { + lines := []string{"[" + name + "]"} + for _, kv := range kvs { + lines = append(lines, kv.Key+" = "+kv.Value) + } + if last := f.sections[len(f.sections)-1]; len(last.lines) > 0 && strings.TrimSpace(last.lines[len(last.lines)-1]) != "" { + lines = append([]string{""}, lines...) + } + f.sections = append(f.sections, §ion{name: name, lines: lines}) + f.trailNL = true +} + +// WriteFile writes data atomically (temp file + rename) with mode 0600. A +// symlinked path is resolved first so the link survives and its target is +// what gets replaced. With backup it first copies the existing target to +// .bak- and returns that path ("" when there was nothing +// to back up). +func WriteFile(path, data string, backup bool, now time.Time) (string, error) { + path, err := resolveLink(path) + if err != nil { + return "", err + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + bak := "" + if old, err := os.ReadFile(path); err == nil && backup { + bak = path + ".bak-" + now.Format("20060102-150405") + if err := os.WriteFile(bak, old, 0o600); err != nil { + return "", fmt.Errorf("backup %s: %w", bak, err) + } + } else if err != nil && !os.IsNotExist(err) { + return "", err + } + tmp, err := os.CreateTemp(dir, ".config-*.tmp") + if err != nil { + return "", err + } + defer os.Remove(tmp.Name()) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return "", err + } + if _, err := tmp.WriteString(data); err != nil { + tmp.Close() + return "", err + } + if err := tmp.Close(); err != nil { + return "", err + } + if err := os.Rename(tmp.Name(), path); err != nil { + return "", err + } + return bak, nil +} + +// resolveLink follows symlinks (including a dangling final link, whose +// target is created) and returns the real file path. +func resolveLink(path string) (string, error) { + if real, err := filepath.EvalSymlinks(path); err == nil { + return real, nil + } + for i := 0; i < 40; i++ { + fi, err := os.Lstat(path) + if os.IsNotExist(err) { + return path, nil + } + if err != nil { + return "", err + } + if fi.Mode()&os.ModeSymlink == 0 { + return path, nil + } + target, err := os.Readlink(path) + if err != nil { + return "", err + } + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(path), target) + } + path = target + } + return "", fmt.Errorf("%s: too many levels of symbolic links", path) +} diff --git a/internal/awsconfig/awsconfig_test.go b/internal/awsconfig/awsconfig_test.go new file mode 100644 index 0000000..9662b36 --- /dev/null +++ b/internal/awsconfig/awsconfig_test.go @@ -0,0 +1,288 @@ +package awsconfig + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +var roles = []string{"javabin-team-cupcake", "javabin-developer"} + +func TestApplyEmptyFile(t *testing.T) { + got, r, err := Apply("", roles, false) + if err != nil { + t.Fatal(err) + } + want := `[sso-session javabin] +sso_start_url = https://identitycenter.amazonaws.com/ssoins-6987654c95c9a069 +sso_region = eu-central-1 +sso_registration_scopes = sso:account:access + +[profile javabin-developer] +sso_session = javabin +sso_account_id = 553637109631 +sso_role_name = javabin-developer +region = eu-central-1 + +[profile javabin-team-cupcake] +sso_session = javabin +sso_account_id = 553637109631 +sso_role_name = javabin-team-cupcake +region = eu-central-1 +` + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } + if !r.SessionChanged || len(r.Added) != 2 { + t.Errorf("result %+v", r) + } +} + +func TestApplyKeepsOtherContent(t *testing.T) { + orig := `# my settings +[default] +region = us-east-1 # comment + +[profile work] +sso_start_url = https://example.awsapps.com/start +sso_role_name = Admin +s3 = + max_concurrent_requests = 20 +` + got, r, err := Apply(orig, roles, false) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(got, orig) { + t.Errorf("existing content changed:\n%s", got) + } + if len(r.Added) != 2 || r.Changed() == false { + t.Errorf("result %+v", r) + } + // unrelated profiles still parse the same + if v := Parse(got).Values("profile work"); v["sso_role_name"] != "Admin" { + t.Errorf("work profile: %v", v) + } +} + +func TestApplyIdempotent(t *testing.T) { + first, _, _ := Apply("[default]\nregion = eu-north-1\n", roles, false) + second, r, err := Apply(first, roles, false) + if err != nil { + t.Fatal(err) + } + if second != first || r.Changed() || len(r.Unchanged) != 2 { + t.Errorf("second run changed things: %+v\n%s", r, second) + } +} + +func TestApplySessionConflict(t *testing.T) { + orig := "[sso-session javabin]\nsso_start_url = https://other.awsapps.com/start\nsso_region = us-east-1\n" + got, _, err := Apply(orig, roles, false) + var conflict *ErrSessionConflict + if !errors.As(err, &conflict) || got != orig { + t.Fatalf("want conflict and unchanged file, got err=%v", err) + } + got, r, err := Apply(orig, roles, true) + if err != nil || !r.SessionChanged { + t.Fatalf("force: err=%v r=%+v", err, r) + } + v := Parse(got).Values(SessionSection) + if v["sso_start_url"] != StartURL || v["sso_region"] != SSORegion || v["sso_registration_scopes"] != "sso:account:access" { + t.Errorf("forced session: %v", v) + } + if strings.Count(got, "[sso-session javabin]") != 1 { + t.Errorf("duplicated session block:\n%s", got) + } +} + +func TestApplySessionCompletesMissingKeys(t *testing.T) { + orig := "[sso-session javabin]\nsso_start_url = " + StartURL + "\nsso_region = eu-central-1\n\n[profile x]\nregion = a\n" + got, r, err := Apply(orig, nil, false) + if err != nil || !r.SessionChanged { + t.Fatalf("err=%v r=%+v", err, r) + } + want := "[sso-session javabin]\nsso_start_url = " + StartURL + "\nsso_region = eu-central-1\nsso_registration_scopes = sso:account:access\n\n[profile x]\nregion = a\n" + if got != want { + t.Errorf("got:\n%s", got) + } +} + +func TestApplyProfileConflictLeftAlone(t *testing.T) { + orig := "[profile javabin-developer]\nsso_role_name = javabin-developer\nsso_account_id = 111111111111\n" + got, r, err := Apply(orig, roles, false) + if err != nil { + t.Fatal(err) + } + if len(r.Conflicts) != 1 || r.Conflicts[0] != "javabin-developer" { + t.Errorf("conflicts %v", r.Conflicts) + } + if v := Parse(got).Values("profile javabin-developer"); v["sso_account_id"] != "111111111111" || v["sso_session"] != "" { + t.Errorf("conflicting profile modified: %v", v) + } +} + +func TestApplyRoleAlreadyCoveredByOtherProfile(t *testing.T) { + orig := "[profile cupcake]\nsso_session = javabin\nsso_account_id = 553637109631\nsso_role_name = javabin-team-cupcake\n" + got, r, _ := Apply(orig, roles, false) + if r.Covered["javabin-team-cupcake"] != "cupcake" || strings.Contains(got, "[profile javabin-team-cupcake]") { + t.Errorf("should not add a second profile for the role: %+v\n%s", r, got) + } +} + +func TestProfileName(t *testing.T) { + for role, want := range map[string]string{"javabin-team-cupcake": "javabin-team-cupcake", "javabin-admin": "javabin-admin", "ReadOnly": "javabin-ReadOnly"} { + if got := ProfileName(role); got != want { + t.Errorf("%s: got %s", role, got) + } + } +} + +func TestWriteFileBackupAndMode(t *testing.T) { + p := filepath.Join(t.TempDir(), "aws", "config") + now := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC) + if bak, err := WriteFile(p, "a\n", true, now); err != nil || bak != "" { + t.Fatalf("new file: bak=%q err=%v", bak, err) + } + bak, err := WriteFile(p, "b\n", true, now) + if err != nil { + t.Fatal(err) + } + if old, _ := os.ReadFile(bak); string(old) != "a\n" || !strings.HasSuffix(bak, "config.bak-20261024-120000") { + t.Errorf("backup %s = %q", bak, old) + } + st, _ := os.Stat(p) + if cur, _ := os.ReadFile(p); string(cur) != "b\n" || st.Mode().Perm() != 0o600 { + t.Errorf("content %q mode %v", cur, st.Mode()) + } +} + +func writeToken(t *testing.T, dir, expires string) { + t.Helper() + sum := sha1.Sum([]byte("javabin")) + body := `{"startUrl":"x","region":"eu-central-1","accessToken":"tok123","expiresAt":"` + expires + `"}` + if err := os.WriteFile(filepath.Join(dir, hex.EncodeToString(sum[:])+".json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestReadToken(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC) + if _, err := ReadToken(dir, "javabin", now); !errors.Is(err, ErrNoToken) { + t.Errorf("missing: %v", err) + } + writeToken(t, dir, "2026-10-24T20:00:00Z") + tok, err := ReadToken(dir, "javabin", now) + if err != nil || tok.AccessToken != "tok123" { + t.Fatalf("valid: %v %+v", err, tok) + } + writeToken(t, dir, "2026-10-24T11:00:00UTC") + if _, err := ReadToken(dir, "javabin", now); !errors.Is(err, ErrNoToken) { + t.Errorf("expired: %v", err) + } +} + +type fakeLister struct{ token, account string } + +func (f *fakeLister) ListRoles(_ context.Context, token, account string) ([]string, error) { + f.token, f.account = token, account + return roles, nil +} + +func TestRoleListerInterface(t *testing.T) { + var l RoleLister = &fakeLister{} + got, _ := l.ListRoles(context.Background(), "tok", AccountID) + if len(got) != 2 { + t.Error(got) + } + var _ RoleLister = SSORoleLister{} +} + +func TestApplyOtherSessionNotCovered(t *testing.T) { + orig := "[profile cupcake]\nsso_session = jb\nsso_account_id = 553637109631\nsso_role_name = javabin-team-cupcake\n" + got, r, _ := Apply(orig, roles, false) + if !strings.Contains(got, "[profile javabin-team-cupcake]") || r.Covered["javabin-team-cupcake"] != "" { + t.Errorf("profile on another session must not count as covered: %+v\n%s", r, got) + } + if refs := r.OtherSession["javabin-team-cupcake"]; len(refs) != 1 || refs[0] != (ProfileRef{"cupcake", "jb"}) { + t.Errorf("OtherSession %v", r.OtherSession) + } + if amb := r.Ambiguous["javabin-team-cupcake"]; len(amb) != 2 { + t.Errorf("Ambiguous %v", r.Ambiguous) + } + if !strings.HasPrefix(got, orig) { + t.Error("existing profile changed") + } +} + +func TestApplyAmbiguousExistingProfiles(t *testing.T) { + orig := "[profile a]\nsso_session = javabin\nsso_role_name = javabin-team-cupcake\n[profile b]\nsso_session = javabin\nsso_role_name = javabin-team-cupcake\n" + _, r, _ := Apply(orig, []string{"javabin-team-cupcake"}, false) + if r.Covered["javabin-team-cupcake"] != "a" || len(r.Ambiguous["javabin-team-cupcake"]) != 2 || len(r.Added) != 0 { + t.Errorf("%+v", r) + } +} + +func TestSetSkipsNestedBlock(t *testing.T) { + f := Parse("[sso-session javabin]\nsso_start_url = " + StartURL + "\ns3 =\n max_concurrent_requests = 20\n\n[profile x]\n") + f.Set(SessionSection, "sso_region", "eu-central-1") + want := "[sso-session javabin]\nsso_start_url = " + StartURL + "\ns3 =\n max_concurrent_requests = 20\nsso_region = eu-central-1\n\n[profile x]\n" + if got := f.String(); got != want { + t.Errorf("got:\n%s", got) + } +} + +func TestParseHeaderWithComment(t *testing.T) { + f := Parse("[profile a] # note\nregion = x\n[profile b] ; other\nregion = y\n[not a header] junk\n") + if f.Values("profile a")["region"] != "x" || f.Values("profile b")["region"] != "y" { + t.Errorf("sections %v", f.SectionNames()) + } + if f.Has("not a header") { + t.Error("trailing junk must not make a header") + } +} + +func TestWriteFileFollowsSymlink(t *testing.T) { + dir := t.TempDir() + realDir := filepath.Join(dir, "dotfiles") + _ = os.MkdirAll(realDir, 0o700) + target := filepath.Join(realDir, "aws-config") + _ = os.WriteFile(target, []byte("old\n"), 0o600) + link := filepath.Join(dir, "config") + if err := os.Symlink("dotfiles/aws-config", link); err != nil { + t.Fatal(err) + } + bak, err := WriteFile(link, "new\n", true, time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if fi, _ := os.Lstat(link); fi.Mode()&os.ModeSymlink == 0 { + t.Error("symlink was replaced by a regular file") + } + if got, _ := os.ReadFile(target); string(got) != "new\n" { + t.Errorf("target = %q", got) + } + realTargetDir, _ := filepath.EvalSymlinks(realDir) + if old, _ := os.ReadFile(bak); string(old) != "old\n" || filepath.Dir(bak) != realTargetDir { + t.Errorf("backup %s = %q", bak, old) + } + if tmps, _ := filepath.Glob(filepath.Join(dir, ".config-*")); len(tmps) != 0 { + t.Errorf("temp files left: %v", tmps) + } + // dangling link: target gets created, link kept + dangling := filepath.Join(dir, "config2") + _ = os.Symlink(filepath.Join(realDir, "new-config"), dangling) + if _, err := WriteFile(dangling, "x\n", true, time.Now()); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(filepath.Join(realDir, "new-config")); string(got) != "x\n" { + t.Errorf("dangling target = %q", got) + } +} diff --git a/internal/awsconfig/plan.go b/internal/awsconfig/plan.go new file mode 100644 index 0000000..2bc4781 --- /dev/null +++ b/internal/awsconfig/plan.go @@ -0,0 +1,192 @@ +package awsconfig + +import ( + "fmt" + "sort" + "strings" +) + +// javaBin Identity Center. +const ( + SessionName = "javabin" + StartURL = "https://identitycenter.amazonaws.com/ssoins-6987654c95c9a069" + SSORegion = "eu-central-1" + AccountID = "553637109631" + Region = "eu-central-1" + scopes = "sso:account:access" +) + +// SessionSection is the header of the javaBin sso-session block. +const SessionSection = "sso-session " + SessionName + +// ProfileName maps an SSO role to its profile name: javabin-* roles keep +// their name (javabin-team-cupcake -> javabin-team-cupcake, javabin-admin -> +// javabin-admin); any other role gets a javabin- prefix so every profile this +// command writes is recognisably javaBin's. +func ProfileName(role string) string { + if strings.HasPrefix(role, "javabin-") { + return role + } + return "javabin-" + role +} + +// ErrSessionConflict means an sso-session javabin block points elsewhere. +type ErrSessionConflict struct{ Existing string } + +func (e *ErrSessionConflict) Error() string { + return fmt.Sprintf("[%s] already exists with sso_start_url = %q (expected %s); not overwriting — rerun with --force to replace its settings", SessionSection, e.Existing, StartURL) +} + +// Result describes what EnsureSession/EnsureProfiles did to the file. +type Result struct { + SessionChanged bool // session block added or updated + Added []string // profiles added + Unchanged []string // profiles already as wanted + Conflicts []string // roles whose ProfileName exists with different settings (left alone) + // Covered maps a role to an existing profile (not named by ProfileName) + // on sso_session javabin that already uses it; no second profile is added + // so `javabin secret` keeps finding exactly one profile per team role. + Covered map[string]string + // OtherSession maps a role to existing profiles that use it through a + // different sso_session (or a legacy inline sso_start_url). They are left + // alone, but `aws sso login --sso-session javabin` does not refresh them. + OtherSession map[string][]ProfileRef + // Ambiguous maps a role to every profile using it when, after the + // change, there is more than one: team-profile auto-discovery is off. + Ambiguous map[string][]string +} + +// ProfileRef names a profile and the sso_session it uses ("" = none). +type ProfileRef struct{ Profile, Session string } + +// Changed reports whether the file content differs. +func (r *Result) Changed() bool { return r.SessionChanged || len(r.Added) > 0 } + +// EnsureSession adds or completes the [sso-session javabin] block. A block +// with a different start URL is an *ErrSessionConflict unless force. +func EnsureSession(f *File, force bool) (bool, error) { + want := []KV{{"sso_start_url", StartURL}, {"sso_region", SSORegion}, {"sso_registration_scopes", scopes}} + vals := f.Values(SessionSection) + if vals == nil { + f.AddSection(SessionSection, want) + return true, nil + } + if u := vals["sso_start_url"]; u != "" && !SameStartURL(u) && !force { + return false, &ErrSessionConflict{Existing: u} + } + changed := false + for _, kv := range want { + if f.Set(SessionSection, kv.Key, kv.Value) { + changed = true + } + } + return changed, nil +} + +// SameStartURL reports whether u is javaBin's start URL, ignoring a trailing +// "/" or "#". +func SameStartURL(u string) bool { return strings.TrimRight(u, "/#") == StartURL } + +// ProfileSettings is what a role's profile should contain. +func ProfileSettings(role string) []KV { + return []KV{ + {"sso_session", SessionName}, + {"sso_account_id", AccountID}, + {"sso_role_name", role}, + {"region", Region}, + } +} + +// EnsureProfiles adds a profile per role. Existing profiles are never edited: +// if one has the wanted settings it is unchanged, otherwise it is reported as +// a conflict. +func EnsureProfiles(f *File, roles []string, r *Result) { + roles = append([]string(nil), roles...) + sort.Strings(roles) + for _, role := range roles { + name := ProfileName(role) + section := "profile " + name + want := ProfileSettings(role) + vals := f.Values(section) + if vals == nil { + covered := "" + for _, ref := range profilesForRole(f, role) { + if ref.Session == SessionName { + if covered == "" { + covered = ref.Profile + } + continue + } + if r.OtherSession == nil { + r.OtherSession = map[string][]ProfileRef{} + } + r.OtherSession[role] = append(r.OtherSession[role], ref) + } + if covered != "" { + if r.Covered == nil { + r.Covered = map[string]string{} + } + r.Covered[role] = covered + continue + } + f.AddSection(section, want) + r.Added = append(r.Added, name) + continue + } + same := true + for _, kv := range want { + if vals[kv.Key] != kv.Value { + same = false + } + } + if same { + r.Unchanged = append(r.Unchanged, name) + } else { + r.Conflicts = append(r.Conflicts, role) + } + } + for _, role := range roles { + if refs := profilesForRole(f, role); len(refs) > 1 { + if r.Ambiguous == nil { + r.Ambiguous = map[string][]string{} + } + for _, ref := range refs { + r.Ambiguous[role] = append(r.Ambiguous[role], ref.Profile) + } + } + } +} + +// profilesForRole lists existing profiles using role in the javaBin account +// (the same rule FindTeamProfile uses for auto-discovery). +func profilesForRole(f *File, role string) []ProfileRef { + var refs []ProfileRef + for _, name := range f.SectionNames() { + p, ok := strings.CutPrefix(name, "profile ") + if !ok && name != "default" { + continue + } + if !ok { + p = name + } + v := f.Values(name) + if v["sso_role_name"] == role && (v["sso_account_id"] == "" || v["sso_account_id"] == AccountID) { + refs = append(refs, ProfileRef{Profile: p, Session: v["sso_session"]}) + } + } + return refs +} + +// Apply runs EnsureSession and EnsureProfiles on data and returns the new +// content. +func Apply(data string, roles []string, force bool) (string, *Result, error) { + f := Parse(data) + r := &Result{} + changed, err := EnsureSession(f, force) + if err != nil { + return data, r, err + } + r.SessionChanged = changed + EnsureProfiles(f, roles, r) + return f.String(), r, nil +} diff --git a/internal/awsconfig/sso.go b/internal/awsconfig/sso.go new file mode 100644 index 0000000..8cb70bd --- /dev/null +++ b/internal/awsconfig/sso.go @@ -0,0 +1,96 @@ +package awsconfig + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/sso" +) + +// DefaultCacheDir is where the AWS CLI caches SSO tokens. +func DefaultCacheDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".aws", "sso", "cache") +} + +// ErrNoToken means there is no usable cached token: run aws sso login. +var ErrNoToken = errors.New("no valid SSO token") + +// Token is the subset of an AWS CLI SSO cache entry we use. +type Token struct { + AccessToken string `json:"accessToken"` + ExpiresAt time.Time `json:"-"` +} + +// ReadToken reads the AWS CLI's cached token for an sso-session +// (/.json) and checks it has not expired. +func ReadToken(cacheDir, session string, now time.Time) (*Token, error) { + sum := sha1.Sum([]byte(session)) + path := filepath.Join(cacheDir, hex.EncodeToString(sum[:])+".json") + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s not found", ErrNoToken, path) + } + if err != nil { + return nil, err + } + var raw struct { + AccessToken string `json:"accessToken"` + ExpiresAt string `json:"expiresAt"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + exp, err := parseExpiry(raw.ExpiresAt) + if err != nil { + return nil, fmt.Errorf("parse %s expiresAt: %w", path, err) + } + if raw.AccessToken == "" || !now.Before(exp) { + return nil, fmt.Errorf("%w: token expired at %s", ErrNoToken, exp.Local().Format(time.RFC3339)) + } + return &Token{AccessToken: raw.AccessToken, ExpiresAt: exp}, nil +} + +func parseExpiry(s string) (time.Time, error) { + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05UTC", "2006-01-02T15:04:05Z0700"} { + if t, err := time.Parse(layout, s); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("unrecognised time %q", s) +} + +// RoleLister lists the SSO roles a token holder has in an account. +type RoleLister interface { + ListRoles(ctx context.Context, accessToken, accountID string) ([]string, error) +} + +// SSORoleLister calls the IAM Identity Center portal API (sso:ListAccountRoles). +type SSORoleLister struct{ Region string } + +func (l SSORoleLister) ListRoles(ctx context.Context, accessToken, accountID string) ([]string, error) { + client := sso.New(sso.Options{Region: l.Region, Credentials: aws.AnonymousCredentials{}}) + p := sso.NewListAccountRolesPaginator(client, &sso.ListAccountRolesInput{ + AccessToken: aws.String(accessToken), + AccountId: aws.String(accountID), + }) + var roles []string + for p.HasMorePages() { + out, err := p.NextPage(ctx) + if err != nil { + return nil, err + } + for _, r := range out.RoleList { + roles = append(roles, aws.ToString(r.RoleName)) + } + } + return roles, nil +}