From 4fd0d703709e2e5d3b7c33ffddd5d28fbf27961f Mon Sep 17 00:00:00 2001 From: winebarrel Date: Tue, 1 Sep 2026 12:24:59 +0900 Subject: [PATCH] Run a command against a different permission set A profile in ~/.aws/config pins exactly one sso_role_name, so reaching a second permission set in the same account has meant editing that file and remembering to put it back. The edit is global and outlives the command it was made for. sr leaves the file alone. The profile still supplies the account, the start URL and the region; only the permission set is substituted, and only for the one child process the credentials are handed to. sr -p example -r ReadOnlyAccess terraform plan The substitution is config.WithSSOProviderOptions, which is applied after ssocreds.New has taken sso_role_name from the profile. That leaves the token cache, and the refresh an [sso-session] profile is capable of, to the SDK. GetRoleCredentials is the only call made: what the account has is never listed, and there is no attempt to sign anyone in. --- .github/workflows/auto-merge.yml | 17 +++ .github/workflows/ci.yml | 28 ++++ .github/workflows/release.yml | 29 ++++ .gitignore | 3 + .golangci.yml | 9 ++ .goreleaser.yml | 17 +++ Makefile | 18 +++ README.md | 117 +++++++++++++++- cmd/sr/main.go | 31 ++++ cmd/sr/version.go | 29 ++++ cmd/sr/version_test.go | 19 +++ codecov.yml | 21 +++ creds.go | 73 ++++++++++ creds_test.go | 80 +++++++++++ env.go | 61 ++++++++ env_test.go | 129 +++++++++++++++++ exec.go | 22 +++ go.mod | 26 ++++ go.sum | 40 ++++++ helper_test.go | 207 +++++++++++++++++++++++++++ renovate.json | 13 ++ role.go | 14 ++ sr.go | 61 ++++++++ sr_test.go | 233 +++++++++++++++++++++++++++++++ 24 files changed, 1296 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/auto-merge.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .golangci.yml create mode 100644 .goreleaser.yml create mode 100644 Makefile create mode 100644 cmd/sr/main.go create mode 100644 cmd/sr/version.go create mode 100644 cmd/sr/version_test.go create mode 100644 codecov.yml create mode 100644 creds.go create mode 100644 creds_test.go create mode 100644 env.go create mode 100644 env_test.go create mode 100644 exec.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 helper_test.go create mode 100644 renovate.json create mode 100644 role.go create mode 100644 sr.go create mode 100644 sr_test.go diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000..a4b2d1d --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,17 @@ +name: Renovate/Dependabot auto-merge +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: github.actor == 'renovate[bot]' || github.actor == 'dependabot[bot]' + steps: + - name: Enable auto-merge for Renovate/Dependabot PRs + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..444ca94 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI +on: + push: + branches: + - main + pull_request: + +defaults: + run: + shell: bash -xe {0} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@v9 + - run: make TEST_OPTS='-coverprofile=coverage.txt' + - uses: codecov/codecov-action@v7 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0fdbf14 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: release +on: + push: + tags: + - v[0-9]+.[0-9]+.[0-9]+ + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index aaadf73..b3b2ad1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# Build output +/sr diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..3cfac9f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,9 @@ +version: "2" +linters: + enable: + - misspell + exclusions: + rules: + - linters: + - errcheck + path: _test\.go diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..806edfb --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,17 @@ +version: 2 +before: + hooks: + - go mod tidy +builds: + - main: ./cmd/sr + ldflags: + - -X main.version={{.Version}} + env: + - CGO_ENABLED=0 + # sr replaces itself with the wrapped command, which has no equivalent on + # Windows. + goos: + - darwin + - linux +checksum: + name_template: "checksums.txt" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bd4c9d8 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: all +all: vet test build + +.PHONY: build +build: + go build ./cmd/sr + +.PHONY: vet +vet: + go vet ./... + +.PHONY: test +test: + go test -v -count=1 ./... $(TEST_OPTS) + +.PHONY: lint +lint: + golangci-lint run diff --git a/README.md b/README.md index 47ca545..28bbd1d 100644 --- a/README.md +++ b/README.md @@ -1 +1,116 @@ -# sr \ No newline at end of file +# sr + +[![CI](https://github.com/winebarrel/sr/actions/workflows/ci.yml/badge.svg)](https://github.com/winebarrel/sr/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/winebarrel/sr/branch/main/graph/badge.svg)](https://codecov.io/gh/winebarrel/sr) +[![AI Generated](https://img.shields.io/badge/AI%20Generated-Claude-orange?logo=anthropic)](https://claude.ai/claude-code) + +Run a command against a different IAM Identity Center permission set. + +A profile in `~/.aws/config` pins exactly one `sso_role_name`, so reaching a +second permission set in the same account means editing that file and +remembering to put it back. The edit is global and outlives the command it was +made for, which is how a `terraform apply` ends up running as an administrator +nobody meant to be. + +`sr` leaves the file alone. The profile still supplies the account, the start +URL and the region; only the permission set is substituted, and only for the +one command you run. + +## Installation + +Download an archive for your platform from the +[releases page](https://github.com/winebarrel/sr/releases) and put the `sr` +binary somewhere on your `PATH`: + +``` +tar xzf sr_Darwin_arm64.tar.gz +install sr /usr/local/bin/ +``` + +Or build it yourself with Go 1.27 or later: + +``` +go install github.com/winebarrel/sr/cmd/sr@latest +``` + +macOS and Linux only: `sr` replaces itself with the command it runs, which has +no equivalent on Windows. + +## Usage + +``` +Usage: sr --role=STRING ... [flags] + +Arguments: + ... Command to run. + +Flags: + -h, --help Show context-sensitive help. + --version + -p, --profile=STRING Profile to take the account, start URL and region + from ($AWS_PROFILE). + -r, --role=STRING Permission set to assume, in full or as an alias. + -a, --alias=KEY=VALUE,... Short names for permission sets, as + 'short=PermissionSetName' ($SR_ALIAS). +``` + +``` +$ sr -p example -r ReadOnlyAccess terraform plan +``` + +`-r` is required: there is no permission set to fall back on, since taking the +one the profile names would run the command as whatever you were trying to +avoid. + +Everything from the command onwards belongs to it, flags included, so nothing +needs escaping: + +``` +$ sr -p example -r ReadOnlyAccess aws s3 ls --region us-east-1 +``` + +A `--` before the command is accepted if you are in the habit of writing one. + +Without `-p`, the profile comes from `AWS_PROFILE` as usual: + +``` +$ AWS_PROFILE=example sr -r ReadOnlyAccess terraform plan +``` + +### Aliases + +Permission set names are long and repetitive to type. `SR_ALIAS` gives them +short names: + +```sh +export SR_ALIAS='ro=ReadOnlyAccess,admin=AdministratorAccess,po=PowerUserAccess' +``` + +``` +$ sr -p example -r ro terraform plan +``` + +The expansion is purely local — a name is either an alias you defined or the +permission set name itself. `sr` never asks Identity Center what exists, so +there is no lookup to wait for and no partial matching to be surprised by. + +## What it does + +1. Loads the profile the way any other AWS tool loads it, with the permission + set you named substituted for its `sso_role_name`. +2. Retrieves credentials for that permission set, using the SSO access token + the AWS CLI cached under `~/.aws/sso/cache`. +3. Replaces itself with your command, with the credentials in its environment. + +`GetRoleCredentials` is the only call it makes, and it makes no attempt to sign +you in: if there is no cached token, or it has expired, it says so and stops. + +``` +$ sr -p example -r ro terraform plan +sr: error: the SSO session has expired or is invalid: run `aws sso login` +``` + +The command runs with `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, +`AWS_SESSION_TOKEN`, `AWS_CREDENTIAL_EXPIRATION` and the region set, and with +`AWS_PROFILE` removed so that nothing sends it back to the permission set the +profile names. diff --git a/cmd/sr/main.go b/cmd/sr/main.go new file mode 100644 index 0000000..62e1221 --- /dev/null +++ b/cmd/sr/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "github.com/alecthomas/kong" + "github.com/winebarrel/sr" +) + +// version is stamped in by GoReleaser at release time. +var version string + +var cli struct { + Version kong.VersionFlag + sr.Cmd +} + +func main() { + kctx := kong.Parse(&cli, + kong.Name("sr"), + kong.Description("Run a command against a different IAM Identity Center permission set."), + kong.Vars{"version": resolveVersion(version)}, + kong.UsageOnError(), + ) + + err := cli.Run(&sr.Context{ + Exec: sr.ExecProcess, + }) + + // Only reached if the command was never started: a successful Run has + // already replaced this process. + kctx.FatalIfErrorf(err) +} diff --git a/cmd/sr/version.go b/cmd/sr/version.go new file mode 100644 index 0000000..c4b321c --- /dev/null +++ b/cmd/sr/version.go @@ -0,0 +1,29 @@ +package main + +import "runtime/debug" + +// resolveVersion returns the version to show for --version. +// +// GoReleaser stamps one in with ldflags at release time. A build made any +// other way has none, so what the Go toolchain embedded is used instead: the +// commit for a build from a checkout, or the module version for an install at +// a tag. +func resolveVersion(version string) string { + if version != "" { + return version + } + + info, ok := debug.ReadBuildInfo() + + if !ok { + return "" + } + + for _, setting := range info.Settings { + if setting.Key == "vcs.revision" { + return setting.Value + } + } + + return info.Main.Version +} diff --git a/cmd/sr/version_test.go b/cmd/sr/version_test.go new file mode 100644 index 0000000..78b0e30 --- /dev/null +++ b/cmd/sr/version_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveVersion(t *testing.T) { + assert := assert.New(t) + + // A stamped version is used as it is. + assert.Equal("1.2.3", resolveVersion("1.2.3")) + + // Without one, the answer comes from what the toolchain embedded, which + // depends on how this binary was built. What matters is that --version + // says something rather than printing an empty line. + assert.NotEmpty(resolveVersion("")) +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..f6a0cd7 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,21 @@ +coverage: + status: + project: + default: + target: 90% + threshold: 1% + patch: + default: + target: 90% + threshold: 0% + +ignore: + # kong wiring with nothing to assert that --help does not already prove. + - "cmd/sr/main.go" + # Reads what the Go toolchain stamped into the binary, which a test binary + # never carries: ReadBuildInfo always succeeds there and no vcs.revision is + # recorded, so neither fallback is reachable from a test. + - "cmd/sr/version.go" + # Replaces the running process, so a test that reached it would not come + # back to report anything. + - "exec.go" diff --git a/creds.go b/creds.go new file mode 100644 index 0000000..811ad97 --- /dev/null +++ b/creds.go @@ -0,0 +1,73 @@ +package sr + +import ( + "context" + "errors" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials/ssocreds" +) + +// credentials resolves the profile with role substituted for its sso_role_name, +// and returns the credentials along with the region the command should run in. +// +// The profile is loaded the way any other AWS tool loads it, so the account, +// the start URL and the region keep coming from where they always came from. +// WithSSOProviderOptions is applied after ssocreds.New has taken sso_role_name +// from the profile, so it wins -- which leaves the token cache, and the +// refresh an [sso-session] profile is capable of, the SDK's business. +// +// A permission set that is not assigned fails when the credentials are +// retrieved. What the account has is never listed: that would cost a call in +// front of the one that matters and would only move the same failure earlier. +func credentials(ctx context.Context, profile, role string, optFns ...func(*config.LoadOptions) error) (aws.Credentials, string, error) { + options := []func(*config.LoadOptions) error{ + config.WithSSOProviderOptions(func(o *ssocreds.Options) { + o.RoleName = role + }), + } + + if profile != "" { + options = append(options, config.WithSharedConfigProfile(profile)) + } + + cfg, err := config.LoadDefaultConfig(ctx, append(options, optFns...)...) + + if err != nil { + return aws.Credentials{}, "", err + } + + // A profile that gets its credentials some other way has no permission set + // to replace, and the override above was quietly ignored. Running the + // command against whatever that profile does use is the one outcome nobody + // asked for. + if !aws.IsCredentialsProvider(cfg.Credentials, (*ssocreds.Provider)(nil)) { + return aws.Credentials{}, "", errors.New("the profile does not use IAM Identity Center") + } + + creds, err := cfg.Credentials.Retrieve(ctx) + + if err != nil { + return aws.Credentials{}, "", withLoginHint(err) + } + + return creds, cfg.Region, nil +} + +// withLoginHint adds the sign-in to an error about the cached token. +// +// sr signs nobody in: that means opening a browser and waiting for someone to +// come back to it, which is not something to do on behalf of a command that +// was asked to run now. Saying so is all it can do. Both a missing token and +// an expired one arrive as InvalidTokenError. +func withLoginHint(err error) error { + var invalidToken *ssocreds.InvalidTokenError + + if errors.As(err, &invalidToken) { + return fmt.Errorf("%w: run `aws sso login`", err) + } + + return err +} diff --git a/creds_test.go b/creds_test.go new file mode 100644 index 0000000..b9bb6c4 --- /dev/null +++ b/creds_test.go @@ -0,0 +1,80 @@ +package sr_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/winebarrel/sr" +) + +// TestCmdRunErrors covers what stops the command from running at all. +func TestCmdRunErrors(t *testing.T) { + tests := []struct { + name string + profile string + role string + token string + status int + errMsg string + }{ + { + // Nobody has signed in on this machine, or the session was logged + // out. + name: "not signed in", + profile: "example", + errMsg: "run `aws sso login`", + }, + { + name: "expired session", + profile: "example", + token: expiredToken(), + errMsg: "run `aws sso login`", + }, + { + // There is no permission set to replace, and the override would + // have gone unnoticed. + name: "not an sso profile", + profile: "keys", + token: validToken(), + errMsg: "the profile does not use IAM Identity Center", + }, + { + name: "unknown profile", + profile: "nope", + token: validToken(), + errMsg: "failed to get shared config profile, nope", + }, + { + // Where a mistyped or unassigned permission set lands. Nothing is + // listed up front, so this is the first sign of it. + name: "permission set not assigned", + profile: "example", + token: validToken(), + status: 403, + errMsg: "ForbiddenException", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + center := startIdentityCenter(t) + center.Status = tt.status + + home := isolateAWS(t, testConfig) + + if tt.token != "" { + writeToken(t, home, tt.token) + } + + cmd := &sr.Cmd{ + Profile: tt.profile, + Role: "ReadOnlyAccess", + Command: []string{"terraform", "plan"}, + } + + err := cmd.Run(center.context(t, refuseToRun(t))) + + assert.ErrorContains(t, err, tt.errMsg) + }) + } +} diff --git a/env.go b/env.go new file mode 100644 index 0000000..6ed7201 --- /dev/null +++ b/env.go @@ -0,0 +1,61 @@ +package sr + +import ( + "slices" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" +) + +// replacedEnv are the variables the child must not simply inherit. +// +// A named profile is what the SDKs reach for once they are done with the +// environment, so leaving one set would point the child back at the permission +// set being replaced. Stale credentials have to go for a different reason: an +// AWS_SESSION_TOKEN from an unrelated session, sent with keys it does not +// belong to, fails in a way that says nothing about why. +var replacedEnv = []string{ + "AWS_PROFILE", + "AWS_DEFAULT_PROFILE", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + "AWS_CREDENTIAL_EXPIRATION", + "AWS_REGION", + "AWS_DEFAULT_REGION", +} + +// childEnv returns base with the credentials and region substituted in. +func childEnv(base []string, creds aws.Credentials, region string) []string { + env := make([]string, 0, len(base)+len(replacedEnv)) + + for _, entry := range base { + name, _, _ := strings.Cut(entry, "=") + + if !slices.Contains(replacedEnv, name) { + env = append(env, entry) + } + } + + env = append(env, + "AWS_ACCESS_KEY_ID="+creds.AccessKeyID, + "AWS_SECRET_ACCESS_KEY="+creds.SecretAccessKey, + "AWS_SESSION_TOKEN="+creds.SessionToken, + // Not read by the SDKs, but it is the only way to tell how long the + // session has left. + "AWS_CREDENTIAL_EXPIRATION="+creds.Expires.UTC().Format(time.RFC3339), + ) + + // Without one, the child resolves a region for itself as it would have + // without sr in the way. + if region != "" { + env = append(env, + "AWS_REGION="+region, + "AWS_DEFAULT_REGION="+region, + ) + } + + return env +} diff --git a/env_test.go b/env_test.go new file mode 100644 index 0000000..0ace571 --- /dev/null +++ b/env_test.go @@ -0,0 +1,129 @@ +package sr_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/winebarrel/sr" +) + +// TestCmdRunEnv covers what the wrapped command inherits. +func TestCmdRunEnv(t *testing.T) { + tests := []struct { + name string + profile string + env map[string]string + expected map[string]string + absent []string + }{ + { + name: "unrelated variables are kept", + profile: "example", + env: map[string]string{"TF_LOG": "DEBUG"}, + expected: map[string]string{ + "TF_LOG": "DEBUG", + "AWS_REGION": "ap-northeast-1", + }, + }, + { + // Left in place, it would send the child back to the very + // permission set the invocation exists to replace. + name: "the profile is dropped", + profile: "example", + env: map[string]string{"AWS_PROFILE": "example", "AWS_DEFAULT_PROFILE": "example"}, + absent: []string{"AWS_PROFILE", "AWS_DEFAULT_PROFILE"}, + expected: map[string]string{"AWS_ACCESS_KEY_ID": "AKIAEXAMPLE"}, + }, + { + // The deprecated spelling has no new value to take, so it has to + // be gone rather than merely overwritten: sent with keys it does + // not belong to, it fails in a way that says nothing about why. + name: "a stale session token does not survive", + profile: "example", + env: map[string]string{"AWS_SECURITY_TOKEN": "stale", "AWS_CREDENTIAL_EXPIRATION": "stale"}, + absent: []string{"AWS_SECURITY_TOKEN"}, + expected: map[string]string{"AWS_SESSION_TOKEN": "token"}, + }, + { + // A region in the shell outranks the profile, here as everywhere + // else, so the command lands where it would have landed on its + // own. + name: "a region in the shell wins", + profile: "example", + env: map[string]string{"AWS_REGION": "eu-central-1"}, + expected: map[string]string{ + "AWS_REGION": "eu-central-1", + "AWS_DEFAULT_REGION": "eu-central-1", + }, + }, + { + // With nothing to pass on, the child resolves a region for itself + // exactly as it would have without sr in the way. + name: "no region at all", + profile: "no-region", + absent: []string{"AWS_REGION", "AWS_DEFAULT_REGION"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + + center := startIdentityCenter(t) + writeToken(t, isolateAWS(t, testConfig), validToken()) + + for name, value := range tt.env { + t.Setenv(name, value) + } + + var env []string + + cmd := &sr.Cmd{ + Profile: tt.profile, + Role: "ReadOnlyAccess", + Command: []string{"terraform", "plan"}, + } + + err := cmd.Run(center.context(t, func(_, gotEnv []string) error { + env = gotEnv + + return nil + })) + + require.NoError(t, err) + + vars := envMap(env) + + for name, value := range tt.expected { + assert.Equal(value, vars[name], name) + } + + for _, name := range tt.absent { + assert.NotContains(vars, name) + } + }) + } +} + +// TestCmdRunCredentialExpiration covers the one variable the SDKs do not read. +// It is how the command, or whoever is reading its output, can tell how long +// the session has left. +func TestCmdRunCredentialExpiration(t *testing.T) { + center := startIdentityCenter(t) + writeToken(t, isolateAWS(t, testConfig), validToken()) + + var env []string + + cmd := &sr.Cmd{Profile: "example", Role: "ReadOnlyAccess", Command: []string{"terraform", "plan"}} + + err := cmd.Run(center.context(t, func(_, gotEnv []string) error { + env = gotEnv + + return nil + })) + + require.NoError(t, err) + assert.Equal(t, expiry.Format(time.RFC3339), envMap(env)["AWS_CREDENTIAL_EXPIRATION"]) +} diff --git a/exec.go b/exec.go new file mode 100644 index 0000000..8056b15 --- /dev/null +++ b/exec.go @@ -0,0 +1,22 @@ +package sr + +import ( + "os/exec" + "syscall" +) + +// ExecProcess replaces the current process with argv, running under env. +// +// Replacing rather than spawning gets sr out of the way once the credentials +// are in place: the terminal, the signals and the exit status all belong to +// the command itself, with no parent left holding the credentials beside it. +// It is also why sr is Unix-only. +func ExecProcess(argv, env []string) error { + path, err := exec.LookPath(argv[0]) + + if err != nil { + return err + } + + return syscall.Exec(path, argv, env) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5617dcc --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/winebarrel/sr + +go 1.27.0 + +require ( + github.com/alecthomas/kong v1.16.1 + github.com/aws/aws-sdk-go-v2 v1.45.1 + github.com/aws/aws-sdk-go-v2/config v1.33.2 + github.com/aws/aws-sdk-go-v2/credentials v1.20.2 + github.com/stretchr/testify v1.12.1 +) + +require ( + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.36.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.41.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.48.0 // indirect + github.com/aws/smithy-go v1.28.1 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..231ee39 --- /dev/null +++ b/go.sum @@ -0,0 +1,40 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v1.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E= +github.com/alecthomas/kong v1.16.1/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks= +github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/config v1.33.2 h1:Pj4+nF2kc4Z+1BJysVPnX9d5dMN7IYFXR4UJaWK2IpA= +github.com/aws/aws-sdk-go-v2/config v1.33.2/go.mod h1:Igw+HTwbR2tsTU/ydifAS9EHAFJ2s/FCgkwQWFnAdE4= +github.com/aws/aws-sdk-go-v2/credentials v1.20.2 h1:VQjZODPNfdikCX2ZZrltw4zNLkcwjyUFDUl2vT9yTwg= +github.com/aws/aws-sdk-go-v2/credentials v1.20.2/go.mod h1:OmeHCn28vZylsBvalLDf7t8fuJ2rHYQprJs+7WuxniI= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw= +github.com/aws/aws-sdk-go-v2/service/signin v1.8.0 h1:bSvKIoLuRGFqGwASgeCQncCJDi9YKKBDEmCEZzOX1uU= +github.com/aws/aws-sdk-go-v2/service/signin v1.8.0/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI= +github.com/aws/aws-sdk-go-v2/service/sso v1.36.0 h1:iivsh357VnfIc18IFWSuoyQEluf8frfWf4cL2Y0JUQw= +github.com/aws/aws-sdk-go-v2/service/sso v1.36.0/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.41.0 h1:wVxM3QzSKIK8tSN6OGgezp9OK91lCLH2zhmRInN9rFM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.41.0/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c= +github.com/aws/aws-sdk-go-v2/service/sts v1.48.0 h1:RzZVCzYM19vhJCT5s6vO2wN8ie770Li/TmbAZ9B6N7E= +github.com/aws/aws-sdk-go-v2/service/sts v1.48.0/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= diff --git a/helper_test.go b/helper_test.go new file mode 100644 index 0000000..24d0e7a --- /dev/null +++ b/helper_test.go @@ -0,0 +1,207 @@ +package sr_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials/ssocreds" + "github.com/stretchr/testify/require" + "github.com/winebarrel/sr" +) + +const startURL = "https://example.awsapps.com/start" + +// testConfig covers both what sr is for -- a profile whose sso_role_name is +// not the permission set being asked for -- and a profile it has to refuse. +const testConfig = ` +[profile example] +sso_start_url = ` + startURL + ` +sso_region = us-east-1 +sso_account_id = 123456789012 +sso_role_name = AdministratorAccess +region = ap-northeast-1 + +[profile no-region] +sso_start_url = ` + startURL + ` +sso_region = us-east-1 +sso_account_id = 123456789012 +sso_role_name = AdministratorAccess + +[profile keys] +aws_access_key_id = AKIAEXAMPLE +aws_secret_access_key = secret +` + +// expiry is when the stub's credentials run out. +var expiry = time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + +// identityCenter is a stub of the one call sr makes. +type identityCenter struct { + // URL is where the stub is listening. + URL string + + // Request is the last request it received, for tests that care what was + // asked for rather than what came back. + Request *http.Request + + // Status, when set, is returned instead of credentials, the way Identity + // Center answers for a permission set that is not assigned. + Status int +} + +// startIdentityCenter starts a stub that answers GetRoleCredentials. +func startIdentityCenter(t *testing.T) *identityCenter { + t.Helper() + + center := &identityCenter{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + center.Request = r + + if center.Status != 0 { + w.Header().Set("X-Amzn-Errortype", "ForbiddenException") + w.WriteHeader(center.Status) + json.NewEncoder(w).Encode(map[string]any{"message": "No access"}) //nolint:errcheck + + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "roleCredentials": map[string]any{ + "accessKeyId": "AKIAEXAMPLE", + "secretAccessKey": "secret", + "sessionToken": "token", + "expiration": expiry.UnixMilli(), + }, + }) + })) + + t.Cleanup(server.Close) + center.URL = server.URL + + return center +} + +// context returns a Context that runs exec and talks to the stub. +func (center *identityCenter) context(t *testing.T, exec func(argv, env []string) error) *sr.Context { + t.Helper() + + target, err := url.Parse(center.URL) + require.NoError(t, err) + + // Every AWS request is sent to the stub instead. Replacing the HTTP client + // is the only way to intercept the Identity Center call: credentials are + // resolved before endpoints are, so the client that makes it is built + // before any configured endpoint exists to be picked up. + client := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.Host = "" + + return http.DefaultTransport.RoundTrip(req) + })} + + return &sr.Context{ + Exec: exec, + LoadOptions: []func(*config.LoadOptions) error{config.WithHTTPClient(client)}, + } +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +// isolateAWS points the AWS configuration at a temporary directory holding +// config, so that whatever the machine running the tests has in ~/.aws cannot +// take part. The directory it returns stands in for the home directory, and is +// where a cached SSO token would be looked for. +func isolateAWS(t *testing.T, config string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "config") + + require.NoError(t, os.WriteFile(path, []byte(config), 0600)) + + t.Setenv("HOME", dir) + t.Setenv("AWS_CONFIG_FILE", path) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "credentials")) + + // Anything the machine running the tests happens to export would otherwise + // reach the profile resolution, the credential chain, or the child. + for _, name := range []string{ + "SR_ALIAS", + "AWS_PROFILE", + "AWS_DEFAULT_PROFILE", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + "AWS_CREDENTIAL_EXPIRATION", + "AWS_REGION", + "AWS_DEFAULT_REGION", + } { + t.Setenv(name, "") + } + + return dir +} + +// writeToken puts body where the AWS CLI would have cached a token for the +// start URL, under the given home directory. +func writeToken(t *testing.T, home, body string) { + t.Helper() + + path, err := ssocreds.StandardCachedTokenFilepath(startURL) + require.NoError(t, err) + require.Equal(t, home, path[:len(home)], "the cache path must be inside the test home directory") + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, os.WriteFile(path, []byte(body), 0600)) +} + +// validToken is a cached token that has not expired. +func validToken() string { + return `{"accessToken":"the-token","expiresAt":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + `"}` +} + +// expiredToken is a cached token from a session that has since ended. +func expiredToken() string { + return `{"accessToken":"the-token","expiresAt":"2020-01-01T00:00:00Z"}` +} + +// envMap turns an environment into a map, keeping the last value set for a +// name the way exec does. +func envMap(env []string) map[string]string { + out := map[string]string{} + + for _, entry := range env { + name, value, _ := strings.Cut(entry, "=") + out[name] = value + } + + return out +} + +// refuseToRun is an Exec for tests where the command must never start. +func refuseToRun(t *testing.T) func(argv, env []string) error { + t.Helper() + + return func(_, _ []string) error { + t.Error("the command must not run") + + return nil + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..cdc4542 --- /dev/null +++ b/renovate.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":disableDependencyDashboard" + ], + "postUpdateOptions": [ + "gomodTidy" + ], + "vulnerabilityAlerts": { + "enabled": true + } +} diff --git a/role.go b/role.go new file mode 100644 index 0000000..c1a07bb --- /dev/null +++ b/role.go @@ -0,0 +1,14 @@ +package sr + +// resolveRole expands a short name that stands in for a permission set. +// +// The expansion is local: a name is either a key of the alias map or already +// the permission set name. Matching what was typed against what the account +// has would cost a ListAccountRoles call before the call that matters. +func resolveRole(name string, alias map[string]string) string { + if full, ok := alias[name]; ok { + return full + } + + return name +} diff --git a/sr.go b/sr.go new file mode 100644 index 0000000..6312c3c --- /dev/null +++ b/sr.go @@ -0,0 +1,61 @@ +// Package sr runs a command against an IAM Identity Center permission set +// other than the one its profile names. +// +// A profile in ~/.aws/config pins exactly one sso_role_name, so reaching a +// second permission set in the same account has meant editing that file and +// remembering to put it back. sr leaves it alone: the profile still supplies +// the account, the start URL and the region, and only the permission set is +// substituted, for one child process. When it exits there is nothing to undo. +package sr + +import ( + "context" + "os" + + "github.com/aws/aws-sdk-go-v2/config" +) + +// Context is what the command needs from its surroundings. +type Context struct { + // Exec runs the wrapped command. The command line supplies ExecProcess; + // tests supply their own. + Exec func(argv, env []string) error + + // LoadOptions are passed to config.LoadDefaultConfig after the ones sr + // supplies itself, and so can override them. Nil is the ordinary case. + LoadOptions []func(*config.LoadOptions) error +} + +// Cmd is the command line. +type Cmd struct { + Profile string `short:"p" env:"AWS_PROFILE" help:"Profile to take the account, start URL and region from."` + Role string `short:"r" required:"" help:"Permission set to assume, in full or as an alias."` + Alias map[string]string `short:"a" env:"SR_ALIAS" mapsep:"," help:"Short names for permission sets, as 'short=PermissionSetName'."` + + Command []string `arg:"" passthrough:"" help:"Command to run."` +} + +// Run resolves credentials for the requested permission set and hands the +// command over to them. Nothing is written anywhere: the credentials exist +// only in the environment of the process that replaces this one. +func (cmd *Cmd) Run(cmdCtx *Context) error { + creds, region, err := credentials(context.Background(), cmd.Profile, resolveRole(cmd.Role, cmd.Alias), cmdCtx.LoadOptions...) + + if err != nil { + return err + } + + return cmdCtx.Exec(trimSeparator(cmd.Command), childEnv(os.Environ(), creds, region)) +} + +// trimSeparator drops the "--" between sr's flags and the command. +// +// Neither the shell nor the parser removes it, so left in place it would be +// taken for the program to run. +func trimSeparator(args []string) []string { + if len(args) > 0 && args[0] == "--" { + return args[1:] + } + + return args +} diff --git a/sr_test.go b/sr_test.go new file mode 100644 index 0000000..6d137a0 --- /dev/null +++ b/sr_test.go @@ -0,0 +1,233 @@ +package sr_test + +import ( + "testing" + "time" + + "github.com/alecthomas/kong" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/winebarrel/sr" +) + +func TestCmdParse(t *testing.T) { + tests := []struct { + name string + args []string + profile string + alias map[string]string + role string + command []string + errMsg string + }{ + { + name: "role and command", + args: []string{"-r", "ReadOnlyAccess", "terraform", "plan"}, + alias: map[string]string{}, + role: "ReadOnlyAccess", + command: []string{"terraform", "plan"}, + }, + { + name: "profile flag", + args: []string{"-p", "example", "-r", "ReadOnlyAccess", "aws", "s3", "ls"}, + profile: "example", + alias: map[string]string{}, + role: "ReadOnlyAccess", + command: []string{"aws", "s3", "ls"}, + }, + { + // Everything from the command onwards belongs to it, including + // anything that looks like a flag of sr's own. + name: "command keeps its own flags", + args: []string{"-r", "ReadOnlyAccess", "aws", "s3", "ls", "--profile", "other", "--version"}, + alias: map[string]string{}, + role: "ReadOnlyAccess", + command: []string{"aws", "s3", "ls", "--profile", "other", "--version"}, + }, + { + // Parsing hands the separator over with the rest; it is Run that + // has to drop it, which TestCmdRunSeparator covers. + name: "explicit separator survives parsing", + args: []string{"-r", "ReadOnlyAccess", "--", "terraform", "plan"}, + alias: map[string]string{}, + role: "ReadOnlyAccess", + command: []string{"--", "terraform", "plan"}, + }, + { + name: "aliases", + args: []string{"-a", "ro=ReadOnlyAccess,admin=AdministratorAccess", "-r", "ro", "terraform", "plan"}, + alias: map[string]string{"ro": "ReadOnlyAccess", "admin": "AdministratorAccess"}, + role: "ro", + command: []string{"terraform", "plan"}, + }, + { + // There is no permission set to fall back on: taking the one the + // profile names would run the command as whatever sr exists to + // avoid. + name: "no role", + args: []string{"terraform", "plan"}, + errMsg: "missing flags: --role", + }, + { + name: "role without a command", + args: []string{"-r", "ReadOnlyAccess"}, + errMsg: `expected " ..."`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + + t.Setenv("AWS_PROFILE", "") + t.Setenv("SR_ALIAS", "") + + var cli struct { + Version kong.VersionFlag + sr.Cmd + } + + parser, err := kong.New(&cli, kong.Name("sr"), kong.Vars{"version": ""}) + require.NoError(t, err) + + _, err = parser.Parse(tt.args) + + if tt.errMsg != "" { + assert.ErrorContains(err, tt.errMsg) + return + } + + require.NoError(t, err) + assert.Equal(tt.profile, cli.Profile) + assert.Equal(tt.alias, cli.Alias) + assert.Equal(tt.role, cli.Role) + assert.Equal(tt.command, cli.Command) + }) + } +} + +func TestCmdRun(t *testing.T) { + assert := assert.New(t) + + center := startIdentityCenter(t) + writeToken(t, isolateAWS(t, testConfig), validToken()) + + var argv, env []string + + cmd := &sr.Cmd{ + Profile: "example", + Role: "ReadOnlyAccess", + Command: []string{"terraform", "plan"}, + } + + err := cmd.Run(center.context(t, func(gotArgv, gotEnv []string) error { + argv, env = gotArgv, gotEnv + + return nil + })) + + require.NoError(t, err) + + // The permission set asked for is the one that was requested, not the + // AdministratorAccess the profile names. + require.NotNil(t, center.Request) + assert.Equal("/federation/credentials", center.Request.URL.Path) + assert.Equal("123456789012", center.Request.URL.Query().Get("account_id")) + assert.Equal("ReadOnlyAccess", center.Request.URL.Query().Get("role_name")) + assert.Equal("the-token", center.Request.Header.Get("x-amz-sso_bearer_token")) + + assert.Equal([]string{"terraform", "plan"}, argv) + + vars := envMap(env) + assert.Equal("AKIAEXAMPLE", vars["AWS_ACCESS_KEY_ID"]) + assert.Equal("secret", vars["AWS_SECRET_ACCESS_KEY"]) + assert.Equal("token", vars["AWS_SESSION_TOKEN"]) + assert.Equal(expiry.Format(time.RFC3339), vars["AWS_CREDENTIAL_EXPIRATION"]) + assert.Equal("ap-northeast-1", vars["AWS_REGION"]) + assert.Equal("ap-northeast-1", vars["AWS_DEFAULT_REGION"]) +} + +// TestCmdRunAlias covers a short name standing in for a permission set. The +// expansion is local, so what reaches Identity Center is the full name. +func TestCmdRunAlias(t *testing.T) { + tests := []struct { + name string + role string + alias map[string]string + expected string + }{ + { + name: "alias", + role: "ro", + alias: map[string]string{"ro": "ReadOnlyAccess"}, + expected: "ReadOnlyAccess", + }, + { + // A permission set name is always usable as written, whether or + // not anyone has given it a short name. + name: "full name passes through", + role: "ReadOnlyAccess", + alias: map[string]string{"ro": "ReadOnlyAccess"}, + expected: "ReadOnlyAccess", + }, + { + // Matching is exact. A permission set differing only in case is a + // different permission set to Identity Center, so guessing at the + // case here would only produce a confusing failure later. + name: "case must match", + role: "RO", + alias: map[string]string{"ro": "ReadOnlyAccess"}, + expected: "RO", + }, + { + // Aliases are read from the environment, so there are often none. + name: "no aliases", + role: "ReadOnlyAccess", + expected: "ReadOnlyAccess", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + center := startIdentityCenter(t) + writeToken(t, isolateAWS(t, testConfig), validToken()) + + cmd := &sr.Cmd{ + Profile: "example", + Role: tt.role, + Alias: tt.alias, + Command: []string{"terraform", "plan"}, + } + + err := cmd.Run(center.context(t, func(_, _ []string) error { return nil })) + + require.NoError(t, err) + assert.Equal(t, tt.expected, center.Request.URL.Query().Get("role_name")) + }) + } +} + +// TestCmdRunSeparator covers the "--" anyone used to a wrapper writes out of +// habit. Neither the shell nor the parser removes it, so left alone it would +// be taken for the program to run. +func TestCmdRunSeparator(t *testing.T) { + center := startIdentityCenter(t) + writeToken(t, isolateAWS(t, testConfig), validToken()) + + var argv []string + + cmd := &sr.Cmd{ + Profile: "example", + Role: "ReadOnlyAccess", + Command: []string{"--", "terraform", "plan"}, + } + + err := cmd.Run(center.context(t, func(gotArgv, _ []string) error { + argv = gotArgv + + return nil + })) + + require.NoError(t, err) + assert.Equal(t, []string{"terraform", "plan"}, argv) +}