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) +}