Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
@@ -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}}
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 }}
29 changes: 29 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/

# Build output
/sr
9 changes: 9 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
version: "2"
linters:
enable:
- misspell
exclusions:
rules:
- linters:
- errcheck
path: _test\.go
17 changes: 17 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 18 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
117 changes: 116 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,116 @@
# sr
# 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 <command> ... [flags]

Arguments:
<command> ... 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.
31 changes: 31 additions & 0 deletions cmd/sr/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
29 changes: 29 additions & 0 deletions cmd/sr/version.go
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions cmd/sr/version_test.go
Original file line number Diff line number Diff line change
@@ -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(""))
}
21 changes: 21 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -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"
Loading