├── docs ├── demo.gif └── logo.svg ├── pkg ├── browser │ ├── windows_noop.go │ ├── linux.go │ ├── opencmd.go │ ├── windows.go │ ├── osx.go │ └── browsers.go ├── securestorage │ ├── errors.go │ ├── cf_token_storage.go │ ├── iam_credential_storage.go │ ├── session_credential_storage.go │ ├── sso_token_storage.go │ └── securestorage.go ├── granted │ ├── middleware │ │ ├── sync.go │ │ └── cli.go │ ├── exp │ │ └── exp.go │ ├── settings │ │ ├── settings.go │ │ ├── requesturl │ │ │ ├── clear.go │ │ │ ├── requesturl.go │ │ │ └── set.go │ │ ├── print.go │ │ ├── frecency.go │ │ ├── export.go │ │ └── set.go │ ├── templates │ │ ├── zsh_autocomplete_granted.tmpl │ │ └── zsh_autocomplete_assume.tmpl │ ├── uninstall.go │ ├── registry │ │ ├── registry_cmd.go │ │ ├── template.go │ │ ├── ini.go │ │ ├── migrate.go │ │ ├── git.go │ │ ├── remove.go │ │ ├── setup.go │ │ └── sync.go │ ├── entrypoint.go │ ├── browser.go │ ├── credential_process.go │ ├── completion.go │ └── console.go ├── assume │ ├── unset.go │ ├── completion.go │ ├── sso.go │ └── entrypoint.go ├── launcher │ ├── firefox.go │ ├── open.go │ ├── safari.go │ └── chrome_profile.go ├── shells │ ├── errors.go │ ├── find_config.go │ └── file.go ├── cfaws │ ├── session_name.go │ ├── session_name_test.go │ ├── env.go │ ├── assumer_aws_google_auth.go │ ├── region_test.go │ ├── cred-exporter.go │ ├── assumer_aws_azure_login.go │ ├── assumers.go │ ├── creds.go │ ├── frecent_profiles.go │ ├── assumer_aws_iam_mfa.go │ ├── region.go │ ├── granted_config_test.go │ ├── assumer_aws_credential_process.go │ ├── access_request.go │ ├── access_request_test.go │ ├── granted_config.go │ ├── assumer_aws_iam.go │ └── ssotoken.go ├── alias │ └── errors.go ├── cache │ └── access_rule.go ├── forkprocess │ ├── forkprocess_windows.go │ └── forkprocess.go ├── assumeprint │ └── output.go ├── autosync │ ├── registry.go │ ├── autosync.go │ └── registry_config.go ├── banners │ └── banners.go ├── urfav_overrides │ ├── flagoverride_test.go │ └── flags.go ├── accessrequest │ └── role.go ├── console │ ├── service_map.go │ ├── partition.go │ └── aws.go └── testable │ └── testable.go ├── internal └── build │ ├── config.go │ ├── urls.go │ └── version.go ├── cmd ├── testing │ ├── browser │ │ └── main.go │ ├── README.md │ └── creds │ │ └── main.go └── granted │ └── main.go ├── pull_request_template.md ├── .gitignore ├── LICENCE ├── scripts ├── assume.bat ├── assume.fish ├── assume.ps1 └── assume ├── README.md ├── Makefile ├── CONTRIBUTING.md └── go.mod /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orenmazor/granted/main/docs/demo.gif -------------------------------------------------------------------------------- /pkg/browser/windows_noop.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package browser 4 | 5 | func HandleWindowsBrowserSearch() (string, error) { 6 | return "", nil 7 | } 8 | -------------------------------------------------------------------------------- /internal/build/config.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | // these configs are overridden as part of the release build process. 4 | var ( 5 | ConfigFolderName = ".dgranted" 6 | ) 7 | -------------------------------------------------------------------------------- /internal/build/urls.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | // these URLs are overridden as part of the release build process. 4 | var ( 5 | UpdateCheckerApiUrl = "localhost:10002" 6 | ) 7 | -------------------------------------------------------------------------------- /pkg/securestorage/errors.go: -------------------------------------------------------------------------------- 1 | package securestorage 2 | 3 | import "github.com/pkg/errors" 4 | 5 | var ErrCouldNotOpenKeyring error = errors.New("keyring not opened successfully") 6 | -------------------------------------------------------------------------------- /cmd/testing/browser/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // This package is used to test browser features in tests 8 | func main() { 9 | os.Exit(0) 10 | } 11 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### What changed? 2 | 3 | 4 | ### Why? 5 | 6 | 7 | ### How did you test it? 8 | 9 | 10 | ### Potential risks 11 | 12 | 13 | ### Is patch release candidate? 14 | 15 | 16 | ### Link to relevant docs PRs -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist/ 3 | bin/ 4 | .DS_Store 5 | granted-config 6 | pkg/api/cert 7 | 8 | # build scripts 9 | .goreleaser.yml 10 | gon.assume.hcl 11 | gon.assume.arm.hcl 12 | gon.granted.hcl 13 | gon.granted.arm.hcl 14 | 15 | launch.json 16 | .env 17 | .vscode/ -------------------------------------------------------------------------------- /pkg/securestorage/cf_token_storage.go: -------------------------------------------------------------------------------- 1 | package securestorage 2 | 3 | type CFTokenStorage struct { 4 | Storage *SecureStorage 5 | } 6 | 7 | func NewCF() CFTokenStorage { 8 | return CFTokenStorage{ 9 | Storage: &SecureStorage{ 10 | StorageSuffix: "cf", 11 | }, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/granted/middleware/sync.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/common-fate/granted/pkg/autosync" 5 | "github.com/urfave/cli/v2" 6 | ) 7 | 8 | func WithAutosync() cli.BeforeFunc { 9 | return func(ctx *cli.Context) error { 10 | autosync.Run(true) 11 | return nil 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/browser/linux.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "os/exec" 5 | 6 | "github.com/common-fate/clio" 7 | ) 8 | 9 | func HandleLinuxBrowserSearch() (string, error) { 10 | out, err := exec.Command("xdg-settings", "get", "default-web-browser").Output() 11 | 12 | if err != nil { 13 | clio.Debug(err.Error()) 14 | } 15 | 16 | return string(out), nil 17 | } 18 | -------------------------------------------------------------------------------- /pkg/assume/unset.go: -------------------------------------------------------------------------------- 1 | package assume 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/common-fate/clio" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | func UnsetAction(c *cli.Context) error { 11 | clio.Success("Environment variables cleared") 12 | // interacts with scripts to unset all the aws environment variables 13 | fmt.Print("GrantedDesume") 14 | return nil 15 | } 16 | -------------------------------------------------------------------------------- /pkg/launcher/firefox.go: -------------------------------------------------------------------------------- 1 | package launcher 2 | 3 | type Firefox struct { 4 | // ExecutablePath is the path to the Firefox binary on the system. 5 | ExecutablePath string 6 | } 7 | 8 | func (l Firefox) LaunchCommand(url string, profile string) []string { 9 | return []string{ 10 | l.ExecutablePath, 11 | "--new-tab", 12 | url, 13 | } 14 | } 15 | 16 | func (l Firefox) UseForkProcess() bool { return true } 17 | -------------------------------------------------------------------------------- /cmd/testing/README.md: -------------------------------------------------------------------------------- 1 | # Testing programs 2 | 3 | These programs are used in CI test pipelines to help test the CLI 4 | 5 | # Browser 6 | 7 | This is just a noop that could be used to simulate a browser program opening successfully 8 | 9 | # Creds 10 | 11 | This program will check the enviroment for variables being set to expected values. 12 | If we add more exported env vars, it will be a good idea to add checks into this program. 13 | -------------------------------------------------------------------------------- /pkg/granted/exp/exp.go: -------------------------------------------------------------------------------- 1 | // Package exp holds experimental commands. 2 | // The API and arguments of these these commands are subject to change. 3 | package exp 4 | 5 | import ( 6 | "github.com/common-fate/granted/pkg/granted/exp/request" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | var Command = cli.Command{ 11 | Name: "experimental", 12 | Aliases: []string{"exp"}, 13 | Subcommands: []*cli.Command{ 14 | &request.Command, 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /pkg/shells/errors.go: -------------------------------------------------------------------------------- 1 | package shells 2 | 3 | import "fmt" 4 | 5 | type ErrLineAlreadyExists struct { 6 | File string 7 | } 8 | 9 | func (e *ErrLineAlreadyExists) Error() string { 10 | return fmt.Sprintf("the line has already been added to %s", e.File) 11 | } 12 | 13 | type ErrLineNotFound struct { 14 | File string 15 | } 16 | 17 | func (e *ErrLineNotFound) Error() string { 18 | return fmt.Sprintf("the line was not found in file %s", e.File) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/cfaws/session_name.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import "github.com/segmentio/ksuid" 4 | 5 | // sessionName returns a unique session identifier for the aws console 6 | // this ensures that user activity can be easily audited per session 7 | // this uses the convenient ksuid library for generating unique IDs 8 | func sessionName() string { 9 | // using the acronym gntd to ensure the id is not longer than 32 chars 10 | return "gntd-" + ksuid.New().String() 11 | } 12 | -------------------------------------------------------------------------------- /pkg/cfaws/session_name_test.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | // sessionName returns a unique session identifier for the aws console 10 | // this ensures that user activity can be easily audited per session 11 | func TestSessionName(t *testing.T) { 12 | // getfederationtoken fails if name is longer than 32 characters long 13 | name := sessionName() 14 | assert.LessOrEqual(t, len(name), 32) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/granted/settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | 6 | "github.com/common-fate/granted/pkg/granted/settings/requesturl" 7 | ) 8 | 9 | var SettingsCommand = cli.Command{ 10 | Name: "settings", 11 | Usage: "Manage Granted settings", 12 | Subcommands: []*cli.Command{&PrintCommand, &ProfileOrderingCommand, &ExportSettingsCommand, &requesturl.Commands, &SetConfigCommand}, 13 | Action: PrintCommand.Action, 14 | } 15 | -------------------------------------------------------------------------------- /pkg/launcher/open.go: -------------------------------------------------------------------------------- 1 | package launcher 2 | 3 | import "github.com/common-fate/granted/pkg/browser" 4 | 5 | // Open calls the 'open' command to open a URL. 6 | // This is the same command as when you run 'open https://commonfate.io' 7 | // in your own terminal. 8 | type Open struct{} 9 | 10 | func (l Open) LaunchCommand(url string, profile string) []string { 11 | cmd := browser.OpenCommand() 12 | return []string{cmd, url} 13 | } 14 | 15 | func (l Open) UseForkProcess() bool { return false } 16 | -------------------------------------------------------------------------------- /pkg/launcher/safari.go: -------------------------------------------------------------------------------- 1 | package launcher 2 | 3 | import "github.com/common-fate/granted/pkg/browser" 4 | 5 | // Open calls the 'open' command to open a URL. 6 | // This is the same command as when you run 'open -a Safari https://commonfate.io' 7 | // in your own terminal. 8 | type Safari struct{} 9 | 10 | func (l Safari) LaunchCommand(url string, profile string) []string { 11 | cmd := browser.OpenCommand() 12 | return []string{cmd, "-a", "Safari", url} 13 | } 14 | 15 | func (l Safari) UseForkProcess() bool { return false } 16 | -------------------------------------------------------------------------------- /pkg/browser/opencmd.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "os/exec" 5 | "runtime" 6 | ) 7 | 8 | // OpenCommand returns the terminal command to open a browser. 9 | // This is system dependent - for MacOS we use 'open', 10 | // whereas for Linux we use 'xdg-open', 'x-www-browser', or 'www-browser'. 11 | func OpenCommand() string { 12 | if runtime.GOOS == "linux" { 13 | cmds := []string{"xdg-open", "x-www-browser", "www-browser"} 14 | for _, c := range cmds { 15 | if _, err := exec.LookPath(c); err == nil { 16 | return c 17 | } 18 | } 19 | } 20 | 21 | return "open" 22 | } 23 | -------------------------------------------------------------------------------- /pkg/browser/windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package browser 4 | 5 | import ( 6 | "github.com/common-fate/clio" 7 | "golang.org/x/sys/windows/registry" 8 | ) 9 | 10 | func HandleWindowsBrowserSearch() (string, error) { 11 | // Lookup https handler in registry 12 | k, err := registry.OpenKey(registry.CURRENT_USER, `SOFTWARE\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\https\\UserChoice`, registry.QUERY_VALUE) 13 | if err != nil { 14 | clio.Debug(err.Error()) 15 | } 16 | kv, _, err := k.GetStringValue("ProgId") 17 | if err != nil { 18 | clio.Debug(err.Error()) 19 | } 20 | return kv, nil 21 | } 22 | -------------------------------------------------------------------------------- /pkg/alias/errors.go: -------------------------------------------------------------------------------- 1 | package alias 2 | 3 | import "fmt" 4 | 5 | type ErrShellNotSupported struct { 6 | Shell string 7 | } 8 | 9 | func (e *ErrShellNotSupported) Error() string { 10 | return fmt.Sprintf("unsupported shell %s", e.Shell) 11 | } 12 | 13 | type ErrAlreadyInstalled struct { 14 | File string 15 | } 16 | 17 | func (e *ErrAlreadyInstalled) Error() string { 18 | return fmt.Sprintf("the Granted alias has already been added to %s", e.File) 19 | } 20 | 21 | type ErrNotInstalled struct { 22 | File string 23 | } 24 | 25 | func (e *ErrNotInstalled) Error() string { 26 | return fmt.Sprintf("the Granted alias hasn't been added to %s", e.File) 27 | } 28 | -------------------------------------------------------------------------------- /internal/build/version.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | var ( 4 | Version = "dev" 5 | Commit = "none" 6 | Date = "unknown" 7 | BuiltBy = "unknown" 8 | ) 9 | 10 | func IsDev() bool { 11 | return Version == "dev" 12 | } 13 | 14 | // AssumeScriptName returns the name of the shell script which wraps the assume binary 15 | func AssumeScriptName() string { 16 | if IsDev() { 17 | return "dassume" 18 | } 19 | return "assume" 20 | } 21 | func AssumeBinaryName() string { 22 | if IsDev() { 23 | return "dassumego" 24 | } 25 | return "assumego" 26 | } 27 | 28 | func GrantedBinaryName() string { 29 | if IsDev() { 30 | return "dgranted" 31 | } 32 | return "granted" 33 | } 34 | -------------------------------------------------------------------------------- /pkg/granted/templates/zsh_autocomplete_granted.tmpl: -------------------------------------------------------------------------------- 1 | #compdef {{ .Program }} 2 | # adapted from https://github.com/urfave/cli/blob/main/autocomplete/zsh_autocomplete 3 | # the {{ .Program }} Program does not yet implement any custom behaviour for auto complete 4 | # It uses the default completion from the urFavCli package 5 | local -a opts 6 | local cur 7 | cur=${words[-1]} 8 | if [[ "$cur" == "-"* ]]; then 9 | opts=("${(@f)$(_CLI_ZSH_AUTOCOMPLETE_HACK=1 ${words[@]:0:#words[@]-1} ${cur} --generate-bash-completion)}") 10 | else 11 | opts=("${(@f)$(_CLI_ZSH_AUTOCOMPLETE_HACK=1 ${words[@]:0:#words[@]-1} --generate-bash-completion)}") 12 | fi 13 | 14 | if [[ "${opts[1]}" != "" ]]; then 15 | _describe 'values' opts 16 | else 17 | _files 18 | fi -------------------------------------------------------------------------------- /pkg/granted/settings/requesturl/clear.go: -------------------------------------------------------------------------------- 1 | package requesturl 2 | 3 | import ( 4 | "fmt" 5 | 6 | grantedConfig "github.com/common-fate/granted/pkg/config" 7 | "github.com/pkg/errors" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | var clearRequestURLCommand = cli.Command{ 12 | Name: "clear", 13 | Usage: "Clears the current request URL", 14 | Action: func(c *cli.Context) error { 15 | gConf, err := grantedConfig.Load() 16 | if err != nil { 17 | return errors.Wrap(err, "unable to load granted config") 18 | } 19 | 20 | gConf.AccessRequestURL = "" 21 | if err := gConf.Save(); err != nil { 22 | return errors.Wrap(err, "saving config") 23 | } 24 | 25 | fmt.Println("Successfully cleared the request URL") 26 | return nil 27 | 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /pkg/securestorage/iam_credential_storage.go: -------------------------------------------------------------------------------- 1 | package securestorage 2 | 3 | import "github.com/aws/aws-sdk-go-v2/aws" 4 | 5 | type IAMCredentialsSecureStorage struct { 6 | SecureStorage SecureStorage 7 | } 8 | 9 | func NewSecureIAMCredentialStorage() IAMCredentialsSecureStorage { 10 | return IAMCredentialsSecureStorage{ 11 | SecureStorage: SecureStorage{ 12 | StorageSuffix: "aws-iam-credentials", 13 | }, 14 | } 15 | } 16 | 17 | func (i *IAMCredentialsSecureStorage) GetCredentials(profile string) (credentials aws.Credentials, err error) { 18 | err = i.SecureStorage.Retrieve(profile, &credentials) 19 | return 20 | } 21 | 22 | func (i *IAMCredentialsSecureStorage) StoreCredentials(profile string, credentials aws.Credentials) (err error) { 23 | err = i.SecureStorage.Store(profile, &credentials) 24 | return 25 | } 26 | -------------------------------------------------------------------------------- /pkg/cache/access_rule.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | type AccessRule struct { 4 | ID string `json:"id"` 5 | Name string `json:"name"` 6 | DeploymentID string `json:"deployment_id"` 7 | TargetProviderID string `json:"target_provider_id"` 8 | TargetProviderType string `json:"target_provider_type"` 9 | CreatedAt int64 `json:"created_at"` 10 | UpdatedAt int64 `json:"updated_at"` 11 | DurationSeconds int `json:"duration_seconds"` 12 | RequiresApproval bool `json:"requires_approval"` 13 | Targets []AccessTarget `json:"targets"` 14 | } 15 | 16 | type AccessTarget struct { 17 | RuleID string `json:"rule_id"` 18 | Type string `json:"type"` 19 | Label string `json:"label"` 20 | Description string `json:"description"` 21 | Value string `json:"value"` 22 | } 23 | -------------------------------------------------------------------------------- /pkg/forkprocess/forkprocess_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package forkprocess 4 | 5 | import ( 6 | "os/exec" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | type Process struct { 12 | UID uint32 13 | GID uint32 14 | Args []string 15 | Workdir string 16 | } 17 | 18 | // New creates a new Process with the current user's user and group ID. 19 | // Call Start() on the returned process to actually start it. 20 | func New(args ...string) (*Process, error) { 21 | p := Process{ 22 | Args: args, 23 | } 24 | return &p, nil 25 | } 26 | 27 | // Start launches a detached process. 28 | // In Windows we fall back to exec.Command(). 29 | func (p *Process) Start() error { 30 | cmd := exec.Command(p.Args[0], p.Args[1:]...) 31 | err := cmd.Start() 32 | if err != nil { 33 | return errors.Wrap(err, "starting command") 34 | } 35 | // detach from this new process because it continues to run 36 | return cmd.Process.Release() 37 | } 38 | -------------------------------------------------------------------------------- /pkg/assumeprint/output.go: -------------------------------------------------------------------------------- 1 | package assumeprint 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | ) 7 | 8 | // SafeOutput formats a string to match the requirements of granted output in the shell script 9 | // Currently in windows, the granted output is handled differently, as linux and mac support the exec cli flag whereas windows does not yet have support 10 | // this method may be changed in future if we implement support for "--exec" in windows 11 | func SafeOutput(s string) string { 12 | // if the GRANTED_ALIAS_CONFIGURED env variable isn't set, 13 | // we aren't running in the context of the `assume` shell script. 14 | // If this is the case, don't add a prefix to the output as we don't have the 15 | // wrapper shell script to parse it. 16 | if os.Getenv("GRANTED_ALIAS_CONFIGURED") != "true" { 17 | return "" 18 | } 19 | out := "GrantedOutput" 20 | if runtime.GOOS != "windows" { 21 | out += "\n" 22 | } else { 23 | out += " " 24 | } 25 | return out + s 26 | } 27 | -------------------------------------------------------------------------------- /pkg/autosync/registry.go: -------------------------------------------------------------------------------- 1 | package autosync 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/common-fate/clio" 8 | "github.com/common-fate/granted/pkg/granted/registry" 9 | ) 10 | 11 | type RegistrySyncError struct { 12 | err error 13 | } 14 | 15 | func (e *RegistrySyncError) Error() string { 16 | return fmt.Sprintf("error syncing profile registry with err: %s", e.err.Error()) 17 | } 18 | 19 | func runSync(rc RegistrySyncConfig, shouldFailForRequiredKeys bool) error { 20 | clio.Info("Syncing Profile Registries") 21 | shouldSilentLog := true 22 | err := registry.SyncProfileRegistries(shouldSilentLog, false, shouldFailForRequiredKeys) 23 | if err != nil { 24 | return &RegistrySyncError{err: err} 25 | } 26 | rc.LastCheckForSync = time.Now().Weekday() 27 | err = rc.Save() 28 | if err != nil { 29 | clio.Debug("unable to save to registry sync config") 30 | return &RegistrySyncError{err: err} 31 | } 32 | clio.Success("Completed syncing Profile Registries") 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /pkg/securestorage/session_credential_storage.go: -------------------------------------------------------------------------------- 1 | package securestorage 2 | 3 | import ( 4 | "github.com/99designs/keyring" 5 | "github.com/aws/aws-sdk-go-v2/aws" 6 | ) 7 | 8 | type SessionCredentialSecureStorage struct { 9 | SecureStorage SecureStorage 10 | } 11 | 12 | func NewSecureSessionCredentialStorage() SessionCredentialSecureStorage { 13 | return SessionCredentialSecureStorage{ 14 | SecureStorage: SecureStorage{ 15 | StorageSuffix: "aws-session-credentials", 16 | }, 17 | } 18 | } 19 | 20 | func (i *SessionCredentialSecureStorage) GetCredentials(profile string) (credentials aws.Credentials, ok bool, err error) { 21 | err = i.SecureStorage.Retrieve(profile, &credentials) 22 | if err == keyring.ErrKeyNotFound { 23 | err = nil 24 | } else if err == nil { 25 | ok = true 26 | } 27 | return 28 | } 29 | 30 | func (i *SessionCredentialSecureStorage) StoreCredentials(profile string, credentials aws.Credentials) (err error) { 31 | err = i.SecureStorage.Store(profile, &credentials) 32 | return 33 | } 34 | -------------------------------------------------------------------------------- /pkg/launcher/chrome_profile.go: -------------------------------------------------------------------------------- 1 | package launcher 2 | 3 | import ( 4 | "fmt" 5 | "hash/fnv" 6 | ) 7 | 8 | type ChromeProfile struct { 9 | // ExecutablePath is the path to the Chrome binary on the system. 10 | ExecutablePath string 11 | // UserDataPath is the path to the Chrome user data directory, 12 | // which we override to put Granted profiles in a specific folder 13 | // for easy management. 14 | UserDataPath string 15 | } 16 | 17 | func (l ChromeProfile) LaunchCommand(url string, profile string) []string { 18 | profileName := chromeProfileName(profile) 19 | return []string{ 20 | l.ExecutablePath, 21 | "--user-data-dir=" + l.UserDataPath, 22 | "--profile-directory=" + profileName, 23 | "--no-first-run", 24 | "--no-default-browser-check", 25 | url, 26 | } 27 | } 28 | 29 | func chromeProfileName(profile string) string { 30 | h := fnv.New32a() 31 | h.Write([]byte(profile)) 32 | 33 | hash := fmt.Sprint(h.Sum32()) 34 | return hash 35 | } 36 | 37 | func (l ChromeProfile) UseForkProcess() bool { return true } 38 | -------------------------------------------------------------------------------- /pkg/granted/middleware/cli.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "github.com/urfave/cli/v2" 4 | 5 | func ShouldShowHelp(c *cli.Context) bool { 6 | args := c.Args().Slice() 7 | // if help argument is provided then skip this check. 8 | for _, arg := range args { 9 | if arg == "-h" || arg == "--help" || arg == "help" { 10 | return true 11 | } 12 | } 13 | return false 14 | } 15 | func WithBeforeFuncs(cmd *cli.Command, funcs ...cli.BeforeFunc) *cli.Command { 16 | // run the commands own before function last if it exists 17 | // this will help to ensure we have meaningful levels of error precedence 18 | // e.g check if deployment config exists before checking for aws credentials 19 | b := cmd.Before 20 | cmd.Before = func(c *cli.Context) error { 21 | // skip before funcs and allows the help to be displayed 22 | if ShouldShowHelp(c) { 23 | return nil 24 | } 25 | for _, f := range funcs { 26 | err := f(c) 27 | if err != nil { 28 | return err 29 | } 30 | } 31 | if b != nil { 32 | err := b(c) 33 | if err != nil { 34 | return err 35 | } 36 | } 37 | return nil 38 | } 39 | return cmd 40 | } 41 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Common Fate 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/testing/creds/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/common-fate/granted/pkg/cfaws" 10 | ) 11 | 12 | type opts struct { 13 | region *string 14 | secretKey *string 15 | accessKeyId *string 16 | } 17 | 18 | func main() { 19 | o := opts{} 20 | fs := flag.FlagSet{} 21 | o.accessKeyId = fs.String("aws-access-key-id", "", "") 22 | o.secretKey = fs.String("aws-secret-key", "", "") 23 | o.region = fs.String("aws-region", "", "") 24 | 25 | err := fs.Parse(os.Args[1:]) 26 | if err != nil { 27 | fmt.Println(err) 28 | os.Exit(1) 29 | } 30 | creds := cfaws.GetEnvCredentials(context.Background()) 31 | if !creds.HasKeys() { 32 | fmt.Println("No credentials set in env") 33 | os.Exit(1) 34 | } 35 | 36 | if creds.AccessKeyID != *o.accessKeyId { 37 | fmt.Println("access key id not equal") 38 | os.Exit(1) 39 | } 40 | if creds.SecretAccessKey != *o.secretKey { 41 | fmt.Println("secret access key not equal") 42 | os.Exit(1) 43 | } 44 | if os.Getenv("AWS_REGION") != *o.region { 45 | fmt.Println("region not set correctly") 46 | os.Exit(1) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkg/granted/settings/requesturl/requesturl.go: -------------------------------------------------------------------------------- 1 | package requesturl 2 | 3 | import ( 4 | "fmt" 5 | 6 | grantedConfig "github.com/common-fate/granted/pkg/config" 7 | "github.com/pkg/errors" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | var Commands = cli.Command{ 13 | Name: "request-url", 14 | Usage: "Set the request URL for credential_process command (connection to Granted Approvals)", 15 | Subcommands: []*cli.Command{&setRequestURLCommand, &clearRequestURLCommand}, 16 | Action: func(c *cli.Context) error { 17 | gConf, err := grantedConfig.Load() 18 | if err != nil { 19 | return errors.Wrap(err, "unable to load granted config") 20 | } 21 | 22 | if gConf.AccessRequestURL == "" { 23 | fmt.Println("Request URL is not configured. You can configure by using 'granted settings request-url set '") 24 | 25 | return nil 26 | } 27 | 28 | fmt.Printf("The current request URL is '%s' \n", gConf.AccessRequestURL) 29 | fmt.Println("You can clear the value using 'granted settings request-url clear'") 30 | fmt.Println("Or you can reset to another value using 'granted settings request-url set '") 31 | return nil 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /pkg/assume/completion.go: -------------------------------------------------------------------------------- 1 | package assume 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/common-fate/clio" 9 | "github.com/common-fate/granted/pkg/cfaws" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | // If there are more than 2 args and the last argument is a "-" then provide completion for the flags. 14 | // 15 | // Else, provide completion for the aws profiles. 16 | // 17 | // You can use `assume -c ` + tab to get profile names or `assume -` + tab to get flags 18 | func Completion(ctx *cli.Context) { 19 | clio.SetLevelFromEnv("GRANTED_LOG") 20 | if ctx.Bool("verbose") { 21 | clio.SetLevelFromString("debug") 22 | } 23 | if len(os.Args) > 2 && strings.HasPrefix(os.Args[len(os.Args)-2], "-") { 24 | // set the ooutput back to std out so that this completion works correctly 25 | ctx.App.Writer = os.Stdout 26 | cli.DefaultAppComplete(ctx) 27 | } else { 28 | // Ignore errors from this function. Tab completion handles graceful degradation back to listing files. 29 | awsProfiles, _ := cfaws.LoadProfilesFromDefaultFiles() 30 | // Tab completion script requires each option to be separated by a newline 31 | fmt.Println(strings.Join(awsProfiles.ProfileNames, "\n")) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/granted/uninstall.go: -------------------------------------------------------------------------------- 1 | package granted 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/AlecAivazis/survey/v2" 7 | "github.com/common-fate/clio" 8 | "github.com/common-fate/granted/pkg/alias" 9 | "github.com/common-fate/granted/pkg/config" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | var UninstallCommand = cli.Command{ 14 | Name: "uninstall", 15 | Usage: "Remove all Granted configuration", 16 | Action: func(c *cli.Context) error { 17 | withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) 18 | in := &survey.Confirm{ 19 | Message: "Are you sure you want to remove your Granted config?", 20 | Default: true, 21 | } 22 | var confirm bool 23 | err := survey.AskOne(in, &confirm, withStdio) 24 | if err != nil { 25 | return err 26 | } 27 | if confirm { 28 | 29 | err = alias.UninstallDefaultShellAlias() 30 | if err != nil { 31 | clio.Error(err.Error()) 32 | } 33 | grantedFolder, err := config.GrantedConfigFolder() 34 | if err != nil { 35 | return err 36 | } 37 | err = os.RemoveAll(grantedFolder) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | clio.Successf("Removed Granted config folder %s\n", grantedFolder) 43 | } 44 | return nil 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /pkg/granted/templates/zsh_autocomplete_assume.tmpl: -------------------------------------------------------------------------------- 1 | #compdef {{ .Program }} 2 | local -a opts 3 | local cur 4 | cur=${words[-1]} 5 | 6 | # adapted from https://github.com/urfave/cli/blob/main/autocomplete/zsh_autocomplete 7 | # Breakdown of the shell script 8 | # '(@f)' split the results of the call to {{ .Program }}go into an array, slit at newlines 9 | # '_CLI_ZSH_AUTOCOMPLETE_HACK=1' something required by urfavcli 10 | # 'FORCE_NO_ALIAS=true {{ .Program }}go' call the {{ .Program }}go Program to avoid shell script interference with auto complete 11 | # '${words[@]:1:#words[@]-1}' slice the arguments (words[@]) starting at index 1 get (number of arguments - 1) 12 | # '--generate-bash-completion' tell urfavcli to run autocomplete 13 | # not sure what cur does exactly, its from the urfav example something to do with cli flags 14 | if [[ "$cur" == "-"* ]]; then 15 | opts=("${(@f)$(FORCE_NO_ALIAS=true {{ .Program }}go ${words[@]:1:#words[@]-1} ${cur} --generate-bash-completion)}") 16 | else 17 | opts=("${(@f)$(FORCE_NO_ALIAS=true {{ .Program }}go ${words[@]:1:#words[@]-1} --generate-bash-completion)}") 18 | fi 19 | 20 | 21 | # if autocomplete is available, print it else show files in the directory as options 22 | if [[ "${opts[1]}" != "" ]]; then 23 | _describe 'values' opts 24 | else 25 | _files 26 | fi -------------------------------------------------------------------------------- /pkg/granted/registry/registry_cmd.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "github.com/common-fate/clio" 5 | "github.com/urfave/cli/v2" 6 | ) 7 | 8 | var ProfileRegistryCommand = cli.Command{ 9 | Name: "registry", 10 | Usage: "Manage Profile Registries", 11 | Description: "Profile Registries allow you to easily share AWS profile configuration in a team.", 12 | Subcommands: []*cli.Command{&AddCommand, &SyncCommand, &RemoveCommand, &MigrateCommand, &SetupCommand}, 13 | Action: func(c *cli.Context) error { 14 | registries, err := GetProfileRegistries() 15 | if err != nil { 16 | return err 17 | } 18 | 19 | if len(registries) == 0 { 20 | clio.Warn("You haven't connected any Profile Registries yet.") 21 | clio.Info("Connect to a Profile Registry by running 'granted registry add -n -u '") 22 | return nil 23 | } 24 | 25 | clio.Info("Granted is currently synced with following registries:") 26 | for i, r := range registries { 27 | clio.Logf("\t %d: %s with name '%s'", (i + 1), r.Config.URL, r.Config.Name) 28 | } 29 | clio.NewLine() 30 | 31 | clio.Info("To add new registry use 'granted registry add '") 32 | clio.Info("To remove a registry use 'granted registry remove' and select from the options") 33 | clio.Info("To sync a registry use 'granted registry sync'") 34 | 35 | return nil 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /pkg/granted/settings/print.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/common-fate/granted/pkg/config" 8 | "github.com/fatih/structs" 9 | "github.com/olekukonko/tablewriter" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | var PrintCommand = cli.Command{ 14 | Name: "print", 15 | Usage: "List Granted Settings", 16 | Action: func(c *cli.Context) error { 17 | cfg, err := config.Load() 18 | if err != nil { 19 | return err 20 | } 21 | data := [][]string{ 22 | {"update-checker-api-url", c.String("update-checker-api-url")}, 23 | } 24 | // display config, this uses reflection to convert the config struct to a map 25 | // it will always show all the values in the config without us having to update it 26 | for k, v := range structs.Map(cfg) { 27 | data = append(data, []string{k, fmt.Sprint(v)}) 28 | } 29 | 30 | table := tablewriter.NewWriter(os.Stderr) 31 | table.SetHeader([]string{"SETTING", "VALUE"}) 32 | table.SetAutoWrapText(false) 33 | table.SetAutoFormatHeaders(true) 34 | table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) 35 | table.SetAlignment(tablewriter.ALIGN_LEFT) 36 | table.SetCenterSeparator("") 37 | table.SetColumnSeparator("") 38 | table.SetRowSeparator("") 39 | table.SetRowLine(true) 40 | table.SetHeaderLine(false) 41 | table.SetBorder(false) 42 | table.SetTablePadding("\t") 43 | table.SetNoWhiteSpace(true) 44 | table.AppendBulk(data) 45 | table.Render() 46 | return nil 47 | }, 48 | } 49 | -------------------------------------------------------------------------------- /pkg/banners/banners.go: -------------------------------------------------------------------------------- 1 | package banners 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/common-fate/granted/internal/build" 7 | ) 8 | 9 | func Granted() string { 10 | return ` 11 | /$$$$$$ /$$ /$$ 12 | /$$__ $$ | $$ | $$ 13 | | $$ \__/ /$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$$ 14 | | $$ /$$$$ /$$__ $$|____ $$| $$__ $$|_ $$_/ /$$__ $$ /$$__ $$ 15 | | $$|_ $$| $$ \__/ /$$$$$$$| $$ \ $$ | $$ | $$$$$$$$| $$ | $$ 16 | | $$ \ $$| $$ /$$__ $$| $$ | $$ | $$ /$$| $$_____/| $$ | $$ 17 | | $$$$$$/| $$ | $$$$$$$| $$ | $$ | $$$$/| $$$$$$$| $$$$$$$ 18 | \______/ |__/ \_______/|__/ |__/ \___/ \_______/ \_______/ 19 | ` 20 | } 21 | 22 | func Assume() string { 23 | return ` 24 | /$$$$$$ 25 | /$$__ $$ 26 | | $$ \ $$ /$$$$$$$ /$$$$$$$ /$$ /$$ /$$$$$$/$$$$ /$$$$$$ 27 | | $$$$$$$$ /$$_____//$$_____/| $$ | $$| $$_ $$_ $$ /$$__ $$ 28 | | $$__ $$| $$$$$$| $$$$$$ | $$ | $$| $$ \ $$ \ $$| $$$$$$$$ 29 | | $$ | $$ \____ $$\____ $$| $$ | $$| $$ | $$ | $$| $$_____/ 30 | | $$ | $$ /$$$$$$$//$$$$$$$/| $$$$$$/| $$ | $$ | $$| $$$$$$$ 31 | |__/ |__/|_______/|_______/ \______/ |__/ |__/ |__/ \_______/ 32 | ` 33 | } 34 | 35 | func WithVersion(in string) string { 36 | return fmt.Sprintf("%s\nVersion: %s\n", in, build.Version) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/urfav_overrides/flagoverride_test.go: -------------------------------------------------------------------------------- 1 | package cfflags 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | var testingFlags = []cli.Flag{ 13 | &cli.BoolFlag{Name: "testbool", Aliases: []string{"c"}, Usage: "Open a web console to the role"}, 14 | &cli.StringFlag{Name: "teststringservice", Aliases: []string{"s"}, Usage: "Specify a service to open the console into"}, 15 | &cli.StringFlag{Name: "teststringregion", Aliases: []string{"r"}, Usage: "Specify a service to open the console into"}, 16 | } 17 | 18 | func TestFlagsPassToCFFlags(t *testing.T) { 19 | 20 | app := cli.App{ 21 | Name: "test", 22 | 23 | Flags: testingFlags, 24 | 25 | Action: func(c *cli.Context) error { 26 | 27 | assumeFlags, err := New("assumeFlags", testingFlags, c) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | booloutcome := assumeFlags.Bool("testbool") 33 | 34 | serviceoutcome := assumeFlags.String("teststringservice") 35 | regionoutcome := assumeFlags.String("teststringregion") 36 | 37 | assert.Equal(t, booloutcome, true) 38 | assert.Equal(t, serviceoutcome, "iam") 39 | assert.Equal(t, regionoutcome, "region-name") 40 | return nil 41 | }, 42 | EnableBashCompletion: true, 43 | } 44 | 45 | os.Args = []string{"", "test-role", "-c", "-s", "iam", "-r", "region-name"} 46 | 47 | err := app.Run(os.Args) 48 | if err != nil { 49 | fmt.Fprintf(os.Stderr, "%s\n", err) 50 | os.Exit(1) 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /pkg/granted/settings/frecency.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/AlecAivazis/survey/v2" 8 | "github.com/common-fate/clio" 9 | "github.com/common-fate/granted/pkg/config" 10 | "github.com/common-fate/granted/pkg/testable" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | var ProfileOrderingCommand = cli.Command{ 15 | Name: "profile-order", 16 | Usage: "Update profile ordering when assuming", 17 | Subcommands: []*cli.Command{&SetProfileOrderingCommand}, 18 | Action: func(c *cli.Context) error { 19 | cfg, err := config.Load() 20 | if err != nil { 21 | return err 22 | } 23 | fmt.Println(cfg.Ordering) 24 | return nil 25 | }, 26 | } 27 | 28 | var SetProfileOrderingCommand = cli.Command{ 29 | Name: "set", 30 | Usage: "Sets the method of ordering IAM profiles in the assume method", 31 | Action: func(c *cli.Context) error { 32 | cfg, err := config.Load() 33 | if err != nil { 34 | return err 35 | } 36 | withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) 37 | in := survey.Select{ 38 | Message: "Select filter type", 39 | Options: []string{"Frecency", "Alphabetical"}, 40 | } 41 | var selection string 42 | clio.NewLine() 43 | err = testable.AskOne(&in, &selection, withStdio) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | cfg.Ordering = selection 49 | err = cfg.Save() 50 | if err != nil { 51 | return err 52 | } 53 | 54 | clio.Success("Set profile ordering to: ", selection) 55 | return nil 56 | 57 | }, 58 | } 59 | -------------------------------------------------------------------------------- /pkg/granted/settings/export.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/AlecAivazis/survey/v2" 8 | "github.com/common-fate/clio" 9 | "github.com/common-fate/granted/pkg/config" 10 | "github.com/common-fate/granted/pkg/testable" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | var ExportSettingsCommand = cli.Command{ 15 | Name: "export-suffix", 16 | Usage: "suffix to be added when exporting credentials using granteds --export flag.", 17 | Subcommands: []*cli.Command{&SetExportSettingsCommand}, 18 | Action: func(c *cli.Context) error { 19 | cfg, err := config.Load() 20 | if err != nil { 21 | return err 22 | } 23 | fmt.Println(cfg.ExportCredentialSuffix) 24 | return nil 25 | }, 26 | } 27 | 28 | var SetExportSettingsCommand = cli.Command{ 29 | Name: "set", 30 | Usage: "sets a suffix to be added when exporting credentials using granteds --export flag.", 31 | Action: func(c *cli.Context) error { 32 | cfg, err := config.Load() 33 | if err != nil { 34 | return err 35 | } 36 | withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) 37 | in := survey.Input{ 38 | Message: "Exported credential suffix:", 39 | } 40 | var selection string 41 | clio.NewLine() 42 | err = testable.AskOne(&in, &selection, withStdio) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | cfg.ExportCredentialSuffix = selection 48 | err = cfg.Save() 49 | if err != nil { 50 | return err 51 | } 52 | 53 | clio.Successf("Set export credential suffix to: %s", selection) 54 | return nil 55 | 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /pkg/cfaws/env.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "fmt" 5 | 6 | "os" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | "github.com/common-fate/clio" 11 | "github.com/common-fate/granted/pkg/testable" 12 | "github.com/joho/godotenv" 13 | ) 14 | 15 | // WriteCredentialsToDotenv will check if a .env file exists and prompt to create one if it does not. 16 | // After the file exists, it will be opened, credentaisl added and then written to disc 17 | func WriteCredentialsToDotenv(region string, creds aws.Credentials) error { 18 | withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) 19 | if _, err := os.Stat("./.env"); os.IsNotExist(err) { 20 | ans := false 21 | err = testable.AskOne(&survey.Confirm{Message: "No .env file found in the current directory, would you like to create one?"}, &ans, withStdio) 22 | if err != nil { 23 | return err 24 | } 25 | if ans { 26 | f, err := os.Create("./.env") 27 | if err != nil { 28 | return err 29 | } 30 | err = f.Close() 31 | if err != nil { 32 | return err 33 | } 34 | clio.Success("Successfully created a new .env file in the current directory") 35 | } else { 36 | return fmt.Errorf(".env file does not exist and creation was aborted") 37 | } 38 | } 39 | 40 | myEnv, err := godotenv.Read() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | myEnv["AWS_ACCESS_KEY_ID"] = creds.AccessKeyID 46 | myEnv["AWS_SECRET_ACCESS_KEY"] = creds.SecretAccessKey 47 | myEnv["AWS_SESSION_TOKEN"] = creds.SessionToken 48 | myEnv["AWS_REGION"] = region 49 | 50 | return godotenv.Write(myEnv, "./.env") 51 | } 52 | -------------------------------------------------------------------------------- /pkg/granted/settings/requesturl/set.go: -------------------------------------------------------------------------------- 1 | package requesturl 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | grantedConfig "github.com/common-fate/granted/pkg/config" 10 | "github.com/pkg/errors" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | var setRequestURLCommand = cli.Command{ 15 | Name: "set", 16 | Usage: "Set the request URL for Common Fate", 17 | Action: func(c *cli.Context) error { 18 | var approvalsURL string 19 | gConf, err := grantedConfig.Load() 20 | if err != nil { 21 | return errors.Wrap(err, "unable to load granted config") 22 | } 23 | 24 | approvalsURL = c.Args().First() 25 | if approvalsURL == "" { 26 | in := &survey.Input{ 27 | Message: "What is the URL of your Common Fate deployment?", 28 | Help: "URL for your Common Fate dashboard from where users can request access \n for e.g: https://example.com", 29 | } 30 | withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) 31 | err := survey.AskOne(in, &approvalsURL, withStdio) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if approvalsURL == "" { 37 | fmt.Println("Common Fate URL not provided. Command aborted.") 38 | return nil 39 | } 40 | } 41 | 42 | parsedURL, err := url.ParseRequestURI(approvalsURL) 43 | if err != nil { 44 | return errors.Wrap(err, "unable to parse provided URL with err") 45 | } 46 | 47 | gConf.AccessRequestURL = parsedURL.String() 48 | err = gConf.Save() 49 | if err != nil { 50 | return err 51 | } 52 | 53 | fmt.Printf("Common Fate URL has been set to '%s'\n", approvalsURL) 54 | return nil 55 | }, 56 | } 57 | -------------------------------------------------------------------------------- /cmd/granted/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "path/filepath" 8 | "runtime" 9 | "syscall" 10 | 11 | "github.com/common-fate/clio" 12 | "github.com/common-fate/clio/clierr" 13 | "github.com/common-fate/granted/internal/build" 14 | "github.com/common-fate/granted/pkg/assume" 15 | "github.com/common-fate/granted/pkg/granted" 16 | "github.com/common-fate/updatecheck" 17 | "github.com/urfave/cli/v2" 18 | ) 19 | 20 | func main() { 21 | updatecheck.Check(updatecheck.GrantedCLI, build.Version, !build.IsDev()) 22 | defer updatecheck.Print() 23 | 24 | c := make(chan os.Signal, 1) 25 | signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) 26 | go func() { 27 | <-c 28 | // restore cursor in case spinner gets stuck 29 | // https://github.com/apppackio/apppack/commit/a711e55238af2402b4b027a73fccc663ec7ba0f4 30 | // https://github.com/briandowns/spinner/issues/122 31 | if runtime.GOOS != "windows" { 32 | fmt.Fprint(os.Stdin, "\033[?25h") 33 | } 34 | os.Exit(130) 35 | }() 36 | 37 | // Use a single binary to keep keychain ACLs simple, swapping behavior via argv[0] 38 | var app *cli.App 39 | switch filepath.Base(os.Args[0]) { 40 | case "assumego", "assumego.exe", "dassumego", "dassumego.exe": 41 | app = assume.GetCliApp() 42 | default: 43 | app = granted.GetCliApp() 44 | } 45 | 46 | err := app.Run(os.Args) 47 | if err != nil { 48 | // if the error is an instance of clierr.PrintCLIErrorer then print the error accordingly 49 | if cliError, ok := err.(clierr.PrintCLIErrorer); ok { 50 | cliError.PrintCLIError() 51 | } else { 52 | clio.Error(err.Error()) 53 | } 54 | os.Exit(1) 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /pkg/granted/registry/template.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "bytes" 5 | "html/template" 6 | "regexp" 7 | 8 | grantedConfig "github.com/common-fate/granted/pkg/config" 9 | ) 10 | 11 | const AUTO_GENERATED_MSG string = `# Granted-Registry Autogenerated Section. DO NOT EDIT. 12 | # This section is automatically generated by Granted (https://granted.dev). Manual edits to this section will be overwritten. 13 | # To edit, clone your profile registry repo, edit granted.yml, and push your changes. You may need to make a pull request depending on the repository settings. 14 | # To stop syncing and remove this section, run 'granted registry remove'.` 15 | 16 | func getAutogeneratedTemplate() string { 17 | return AUTO_GENERATED_MSG 18 | } 19 | 20 | type ConfigTemplateVariables struct { 21 | Required map[string]string 22 | Variables map[string]string 23 | Profile string 24 | } 25 | 26 | func interpolateVariables(r *Registry, value string, profileName string) (string, error) { 27 | gConf, err := grantedConfig.Load() 28 | if err != nil { 29 | return "", err 30 | } 31 | 32 | d := ConfigTemplateVariables{ 33 | Variables: gConf.ProfileRegistry.Variables, 34 | Required: gConf.ProfileRegistry.RequiredKeys, 35 | Profile: profileName, 36 | } 37 | 38 | tmpl, err := template.New("registry-variable").Parse(value) 39 | if err != nil { 40 | return "", err 41 | } 42 | 43 | buf := &bytes.Buffer{} 44 | err = tmpl.Execute(buf, d) 45 | if err != nil { 46 | panic(err) 47 | } 48 | return buf.String(), nil 49 | } 50 | 51 | func containsTemplate(text string) bool { 52 | re := regexp.MustCompile(`{{\s+(.\w+){1,2}\s+}}`) 53 | 54 | return re.MatchString(text) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/securestorage/sso_token_storage.go: -------------------------------------------------------------------------------- 1 | package securestorage 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/common-fate/clio" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | type SSOTokensSecureStorage struct { 11 | SecureStorage SecureStorage 12 | } 13 | 14 | func NewSecureSSOTokenStorage() SSOTokensSecureStorage { 15 | return SSOTokensSecureStorage{ 16 | SecureStorage: SecureStorage{ 17 | StorageSuffix: "aws-sso-tokens", 18 | }, 19 | } 20 | } 21 | 22 | type SSOToken struct { 23 | AccessToken string 24 | Expiry time.Time 25 | } 26 | 27 | // GetValidSSOToken returns nil if no token was found, or if it is expired 28 | func (s *SSOTokensSecureStorage) GetValidSSOToken(profileKey string) *SSOToken { 29 | var t SSOToken 30 | err := s.SecureStorage.Retrieve(profileKey, &t) 31 | if err != nil { 32 | clio.Debugf("%s\n", errors.Wrap(err, "GetValidCachedToken").Error()) 33 | } 34 | if t.Expiry.Before(time.Now()) { 35 | return nil 36 | } 37 | return &t 38 | } 39 | 40 | // Attempts to store the token, any errors will be logged to debug logging 41 | func (s *SSOTokensSecureStorage) StoreSSOToken(profileKey string, ssoTokenValue SSOToken) { 42 | err := s.SecureStorage.Store(profileKey, ssoTokenValue) 43 | if err != nil { 44 | clio.Debugf("%s\n", errors.Wrap(err, "writing sso token to credentials cache").Error()) 45 | } 46 | 47 | } 48 | 49 | // Attempts to clear the token, any errors will be logged to debug logging 50 | func (s *SSOTokensSecureStorage) ClearSSOToken(profileKey string) { 51 | err := s.SecureStorage.Clear(profileKey) 52 | if err != nil { 53 | clio.Debugf("%s\n", errors.Wrap(err, "clearing sso token from the credentials cache").Error()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pkg/granted/registry/ini.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "path/filepath" 7 | 8 | "github.com/common-fate/clio" 9 | "gopkg.in/ini.v1" 10 | ) 11 | 12 | // Find the ~/.aws/config absolute path based on OS. 13 | func getDefaultAWSConfigLocation() (string, error) { 14 | h, err := os.UserHomeDir() 15 | if err != nil { 16 | return "", err 17 | } 18 | 19 | configPath := filepath.Join(h, ".aws", "config") 20 | return configPath, nil 21 | } 22 | 23 | func loadAWSConfigFile() (*ini.File, string, error) { 24 | filepath, err := getDefaultAWSConfigLocation() 25 | if err != nil { 26 | return nil, "", err 27 | } 28 | 29 | awsConfig, err := ini.LoadSources(ini.LoadOptions{ 30 | SkipUnrecognizableLines: true, 31 | AllowNonUniqueSections: true, 32 | }, filepath) 33 | if err != nil { 34 | return nil, "", err 35 | } 36 | 37 | return awsConfig, filepath, nil 38 | } 39 | 40 | // load all cloned configs of a single repo into one ini object. 41 | // this will overwrite if there are duplicate profiles with same name. 42 | func loadClonedConfigs(r Registry) (*ini.File, error) { 43 | clonedFile := ini.Empty() 44 | 45 | repoDirPath, err := getRegistryLocation(r.Config) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | for _, cfile := range r.AwsConfigPaths { 51 | var filepath string 52 | if r.Config.Path != nil { 53 | filepath = path.Join(repoDirPath, *r.Config.Path, cfile) 54 | } else { 55 | filepath = path.Join(repoDirPath, cfile) 56 | } 57 | 58 | clio.Debugf("loading aws config file from %s", filepath) 59 | err := clonedFile.Append(filepath) 60 | if err != nil { 61 | return nil, err 62 | } 63 | } 64 | 65 | return clonedFile, nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/autosync/autosync.go: -------------------------------------------------------------------------------- 1 | package autosync 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/common-fate/clio" 7 | "github.com/common-fate/granted/pkg/granted/registry" 8 | ) 9 | 10 | // shouldFailForRequiredKeys when true will fail the profile registry sync 11 | // in case where user specific values that are defined in granted.yml's `templateValues` are not available. 12 | // this is done so that users are aware of required keys when granted's credential-process is used through the AWS CLI. 13 | func Run(shouldFailForRequiredKeys bool) { 14 | if registry.IsOutdatedConfig() { 15 | clio.Warn("Outdated Profile Registry Configuration. Use `granted registry migrate` to update your configuration.") 16 | 17 | clio.Warn("Skipping Profile Registry sync.") 18 | 19 | return 20 | } 21 | 22 | registries, err := registry.GetProfileRegistries() 23 | if err != nil { 24 | clio.Debugf("unable to load granted config file with err %s", err.Error()) 25 | return 26 | } 27 | 28 | // check if registry has been configured or not. 29 | // should skip registry sync if no profile registry. 30 | if len(registries) == 0 { 31 | clio.Debug("profile registry not configured. Skipping auto sync.") 32 | return 33 | } 34 | 35 | // load and check if sync has been run for the day. If true then skip. 36 | rc, ok := loadRegistryConfig() 37 | clio.Debug("checking if autosync has been run for the day") 38 | if ok && time.Now().Weekday() == rc.LastCheckForSync { 39 | clio.Debug("skipping profile registry sync until tomorrow=%s", rc.Path()) 40 | return 41 | } 42 | 43 | err = runSync(rc, shouldFailForRequiredKeys) 44 | if err != nil { 45 | clio.Debugw("failed to sync profile registries", "error", err) 46 | clio.Warn("Failed to sync Profile Registries") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkg/cfaws/assumer_aws_google_auth.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/aws/aws-sdk-go-v2/aws" 11 | "github.com/aws/aws-sdk-go-v2/config" 12 | "gopkg.in/ini.v1" 13 | ) 14 | 15 | // Implements Assumer 16 | type AwsGoogleAuthAssumer struct { 17 | } 18 | 19 | // launch the aws-google-auth utility to generate the credentials 20 | // then fetch them from the environment for use 21 | func (aia *AwsGoogleAuthAssumer) AssumeTerminal(ctx context.Context, c *Profile, configOpts ConfigOpts) (aws.Credentials, error) { 22 | cmd := exec.Command("aws-google-auth", fmt.Sprintf("--profile=%s", c.Name)) 23 | 24 | cmd.Stdout = os.Stderr 25 | cmd.Stdin = os.Stdin 26 | cmd.Stderr = os.Stderr 27 | err := cmd.Run() 28 | if err != nil { 29 | return aws.Credentials{}, err 30 | } 31 | creds := GetEnvCredentials(ctx) 32 | if !creds.HasKeys() { 33 | return aws.Credentials{}, fmt.Errorf("no credentials exported to terminal when using %s to assume profile: %s", aia.Type(), c.Name) 34 | } 35 | return creds, nil 36 | } 37 | 38 | func (aia *AwsGoogleAuthAssumer) AssumeConsole(ctx context.Context, c *Profile, configOpts ConfigOpts) (aws.Credentials, error) { 39 | return aia.AssumeTerminal(ctx, c, configOpts) 40 | } 41 | 42 | // A unique key which identifies this assumer e.g AWS-SSO or GOOGLE-AWS-AUTH 43 | func (aia *AwsGoogleAuthAssumer) Type() string { 44 | return "AWS_GOOGLE_AUTH" 45 | } 46 | 47 | // inspect for any items on the profile prefixed with "google_config." 48 | func (aia *AwsGoogleAuthAssumer) ProfileMatchesType(rawProfile *ini.Section, parsedProfile config.SharedConfig) bool { 49 | for _, k := range rawProfile.KeyStrings() { 50 | if strings.HasPrefix(k, "google_config.") { 51 | return true 52 | } 53 | } 54 | return false 55 | } 56 | -------------------------------------------------------------------------------- /pkg/browser/osx.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "encoding/xml" 5 | "os" 6 | "os/exec" 7 | 8 | "github.com/common-fate/clio" 9 | ) 10 | 11 | type plist struct { 12 | // XMLName xml.Name `xml:"plist"` 13 | Pdict Pdict `xml:"dict"` 14 | } 15 | 16 | type Pdict struct { 17 | // XMLName xml.Name `xml:"dict"` 18 | Key string `xml:"key"` 19 | Array Array `xml:"array"` 20 | } 21 | 22 | type Array struct { 23 | // XMLName xml.Name `xml:"array"` 24 | Dict Dict `xml:"dict"` 25 | } 26 | 27 | type Dict struct { 28 | // XMLName xml.Name `xml:"dict"` 29 | Key []string `xml:"key"` 30 | Dict IntDict `xml:"dict"` 31 | Strings []string `xml:"string"` 32 | } 33 | 34 | type IntDict struct { 35 | // XMLName xml.Name `xml:"dict"` 36 | Key string `xml:"key"` 37 | Strings string `xml:"string"` 38 | } 39 | 40 | func HandleOSXBrowserSearch() (string, error) { 41 | // get home dir 42 | home, err := os.UserHomeDir() 43 | if err != nil { 44 | return "", err 45 | } 46 | path := home + "/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist" 47 | 48 | // convert plist to xml using putil 49 | // plutil -convert xml1 50 | args := []string{"-convert", "xml1", path} 51 | cmd := exec.Command("plutil", args...) 52 | err = cmd.Run() 53 | if err != nil { 54 | clio.Debug(err.Error()) 55 | } 56 | 57 | // read plist file 58 | data, err := os.ReadFile(path) 59 | 60 | if err != nil { 61 | clio.Debug(err.Error()) 62 | } 63 | plist := &plist{} 64 | 65 | // unmarshal the xml into the structs 66 | err = xml.Unmarshal([]byte(data), &plist) 67 | if err != nil { 68 | clio.Debug(err.Error()) 69 | } 70 | 71 | // get out the default browser 72 | 73 | for i, s := range plist.Pdict.Array.Dict.Strings { 74 | if s == "http" { 75 | return plist.Pdict.Array.Dict.Strings[i-1], nil 76 | } 77 | } 78 | return "", nil 79 | } 80 | -------------------------------------------------------------------------------- /pkg/cfaws/region_test.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import "testing" 4 | 5 | func TestExpandRegion(t *testing.T) { 6 | type args struct { 7 | region string 8 | } 9 | tests := []struct { 10 | name string 11 | args args 12 | want string 13 | wantErr bool 14 | }{ 15 | {"", args{""}, "us-east-1", false}, 16 | {"us-east-1", args{"us-east-1"}, "us-east-1", false}, 17 | {"ue1", args{"ue1"}, "us-east-1", false}, 18 | {"ue", args{"ue"}, "us-east-1", false}, 19 | {"afs1", args{"afs1"}, "af-south-1", false}, 20 | {"apse3", args{"apse3"}, "ap-southeast-3", false}, 21 | {"cc", args{"cc"}, "ca-central-1", false}, 22 | {"cc1", args{"cc1"}, "ca-central-1", false}, 23 | {"ec1", args{"ec1"}, "eu-central-1", false}, 24 | {"euc1", args{"euc1"}, "eu-central-1", false}, 25 | {"ms1", args{"ms1"}, "me-south-1", false}, 26 | {"se1", args{"se1"}, "sa-east-1", false}, 27 | {"uge1", args{"uge1"}, "us-gov-east-1", false}, 28 | {"cnn1", args{"cnn1"}, "cn-north-1", false}, 29 | {"as1", args{"as1"}, "ap-south-1", false}, 30 | {"ase1", args{"ase1"}, "ap-southeast-1", false}, 31 | 32 | // Special cases 33 | {"mes1", args{"mes1"}, "me-south-1", false}, 34 | {"use1", args{"use1"}, "us-east-1", false}, 35 | 36 | {"???", args{"???"}, "", true}, // Completely invalid 37 | {"a", args{"a"}, "", true}, // Right major, too short 38 | {"ax", args{"ax"}, "", true}, // Right major, too short 39 | {"aee", args{"aee"}, "", true}, // Right major & minor, trailing crap 40 | 41 | } 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | got, err := ExpandRegion(tt.args.region) 45 | if (err != nil) != tt.wantErr { 46 | t.Errorf("expandRegion() error = %v, wantErr %v", err, tt.wantErr) 47 | return 48 | } 49 | if got != tt.want { 50 | t.Errorf("expandRegion() = %v, want %v", got, tt.want) 51 | } 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/accessrequest/role.go: -------------------------------------------------------------------------------- 1 | // Package accessrequest handles 2 | // making requests to roles that a 3 | // user doesn't have access to. 4 | package accessrequest 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "net/url" 10 | "os" 11 | "path/filepath" 12 | 13 | "github.com/common-fate/clio/clierr" 14 | "github.com/common-fate/granted/pkg/config" 15 | ) 16 | 17 | type Role struct { 18 | Account string `json:"account"` 19 | Role string `json:"role"` 20 | } 21 | 22 | func (r Role) URL(dashboardURL string) string { 23 | u, err := url.Parse(dashboardURL) 24 | if err != nil { 25 | return fmt.Sprintf("error building access request URL: %s", err.Error()) 26 | } 27 | u.Path = "access" 28 | q := u.Query() 29 | q.Add("type", "commonfate/aws-sso") 30 | q.Add("permissionSetArn.label", r.Role) 31 | q.Add("accountId", r.Account) 32 | u.RawQuery = q.Encode() 33 | 34 | return u.String() 35 | } 36 | 37 | func (r Role) Save() error { 38 | roleBytes, err := json.Marshal(r) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | configFolder, err := config.GrantedConfigFolder() 44 | if err != nil { 45 | return err 46 | } 47 | 48 | file := filepath.Join(configFolder, "latest-role") 49 | return os.WriteFile(file, roleBytes, 0644) 50 | } 51 | 52 | func LatestRole() (*Role, error) { 53 | configFolder, err := config.GrantedConfigFolder() 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | file := filepath.Join(configFolder, "latest-role") 59 | 60 | if _, err := os.Stat(file); os.IsNotExist(err) { 61 | return nil, clierr.New("no latest role saved", clierr.Info("You can run 'assume' to try and access a role. If the role is inaccessible it will be saved as the latest role.")) 62 | } 63 | 64 | roleBytes, err := os.ReadFile(file) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | var r Role 70 | err = json.Unmarshal(roleBytes, &r) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return &r, nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/shells/find_config.go: -------------------------------------------------------------------------------- 1 | package shells 2 | 3 | import ( 4 | "os" 5 | "path" 6 | ) 7 | 8 | func GetFishConfigFile() (string, error) { 9 | home, err := os.UserHomeDir() 10 | if err != nil { 11 | return "", err 12 | } 13 | file := path.Join(home, ".config", "fish", "config.fish") 14 | 15 | // check that the file exists; create it if not 16 | if _, err := os.Stat(file); os.IsNotExist(err) { 17 | f, err := os.Create(file) 18 | if err != nil { 19 | return "", err 20 | } 21 | defer f.Close() 22 | } 23 | return file, nil 24 | } 25 | 26 | func GetBashConfigFile() (string, error) { 27 | 28 | bashLoginFiles := []string{ 29 | ".bash_profile", 30 | ".bash_login", 31 | ".profile", 32 | ".bashrc", 33 | } 34 | home, err := os.UserHomeDir() 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | for _, f := range bashLoginFiles { 40 | file := path.Join(home, f) 41 | if _, err := os.Stat(file); err == nil { 42 | 43 | return file, nil 44 | } 45 | } 46 | // if we got here, none of the bash login files we tried above work 47 | // so use the .bash_profile 48 | file := path.Join(home, ".bash_profile") 49 | 50 | // check that the file exists; create it if not 51 | if _, err := os.Stat(file); os.IsNotExist(err) { 52 | f, err := os.Create(file) 53 | if err != nil { 54 | return "", err 55 | } 56 | defer f.Close() 57 | } 58 | 59 | return file, nil 60 | } 61 | 62 | func GetZshConfigFile() (string, error) { 63 | // ZDOTDIR is used by ZSH for loading config 64 | dir := os.Getenv("ZDOTDIR") 65 | 66 | if dir == "" { 67 | // fallback to the home directory if ZDOTDIR isn't set 68 | home, err := os.UserHomeDir() 69 | if err != nil { 70 | return "", err 71 | } 72 | dir = home 73 | } 74 | 75 | file := path.Join(dir, ".zshenv") 76 | 77 | // check that the file exists; create it if not 78 | if _, err := os.Stat(file); os.IsNotExist(err) { 79 | f, err := os.Create(file) 80 | if err != nil { 81 | return "", err 82 | } 83 | defer f.Close() 84 | } 85 | return file, nil 86 | } 87 | -------------------------------------------------------------------------------- /pkg/cfaws/cred-exporter.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | "github.com/aws/aws-sdk-go-v2/config" 8 | "github.com/common-fate/clio" 9 | gconfig "github.com/common-fate/granted/pkg/config" 10 | "gopkg.in/ini.v1" 11 | ) 12 | 13 | // ExportCredsToProfile will write assumed credentials to ~/.aws/credentials with a specified profile name header 14 | func ExportCredsToProfile(profileName string, creds aws.Credentials) error { 15 | // fetch the parsed cred file 16 | credPath := config.DefaultSharedCredentialsFilename() 17 | 18 | // create it if it doesn't exist 19 | if _, err := os.Stat(credPath); os.IsNotExist(err) { 20 | 21 | f, err := os.Create(credPath) 22 | if err != nil { 23 | return err 24 | } 25 | err = f.Close() 26 | if err != nil { 27 | return err 28 | } 29 | clio.Infof("An AWS credentials file was not found at %s so it has been created", credPath) 30 | 31 | } 32 | 33 | credentialsFile, err := ini.LoadSources(ini.LoadOptions{ 34 | AllowNonUniqueSections: false, 35 | SkipUnrecognizableLines: false, 36 | }, credPath) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | cfg, err := gconfig.Load() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | if cfg.ExportCredentialSuffix != "" { 47 | profileName = profileName + "-" + cfg.ExportCredentialSuffix 48 | } 49 | 50 | credentialsFile.DeleteSection(profileName) 51 | section, err := credentialsFile.NewSection(profileName) 52 | if err != nil { 53 | return err 54 | } 55 | // put the creds into options 56 | err = section.ReflectFrom(&struct { 57 | AWSAccessKeyID string `ini:"aws_access_key_id"` 58 | AWSSecretAccessKey string `ini:"aws_secret_access_key"` 59 | AWSSessionToken string `ini:"aws_session_token,omitempty"` 60 | }{ 61 | AWSAccessKeyID: creds.AccessKeyID, 62 | AWSSecretAccessKey: creds.SecretAccessKey, 63 | AWSSessionToken: creds.SessionToken, 64 | }) 65 | if err != nil { 66 | return err 67 | } 68 | return credentialsFile.SaveTo(credPath) 69 | } 70 | -------------------------------------------------------------------------------- /pkg/cfaws/assumer_aws_azure_login.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/aws/aws-sdk-go-v2/aws" 11 | "github.com/aws/aws-sdk-go-v2/config" 12 | "gopkg.in/ini.v1" 13 | ) 14 | 15 | // Implements Assumer 16 | type AwsAzureLoginAssumer struct { 17 | } 18 | 19 | // https://github.com/sportradar/aws-azure-login 20 | 21 | // then fetch them from the environment for use 22 | func (aal *AwsAzureLoginAssumer) AssumeTerminal(ctx context.Context, c *Profile, configOpts ConfigOpts) (aws.Credentials, error) { 23 | // check to see if the creds are already exported 24 | creds, err := GetCredentialsCreds(ctx, c) 25 | 26 | if err == nil { 27 | return creds, nil 28 | } 29 | 30 | // request for the creds if they are invalid 31 | a := []string{fmt.Sprintf("--profile=%s", c.Name)} 32 | a = append(a, configOpts.Args...) 33 | 34 | cmd := exec.Command("aws-azure-login", a...) 35 | 36 | cmd.Stdout = os.Stderr 37 | cmd.Stdin = os.Stdin 38 | cmd.Stderr = os.Stderr 39 | err = cmd.Run() 40 | if err != nil { 41 | return aws.Credentials{}, err 42 | } 43 | // reload the profile from disk to check for the new credentials 44 | cfg, err := config.LoadDefaultConfig(ctx, 45 | config.WithSharedConfigProfile(c.Name), 46 | ) 47 | if err != nil { 48 | return aws.Credentials{}, err 49 | } 50 | return aws.NewCredentialsCache(cfg.Credentials).Retrieve(ctx) 51 | } 52 | 53 | func (aal *AwsAzureLoginAssumer) AssumeConsole(ctx context.Context, c *Profile, configOpts ConfigOpts) (aws.Credentials, error) { 54 | return aal.AssumeTerminal(ctx, c, configOpts) 55 | } 56 | 57 | // A unique key which identifies this assumer e.g AWS-SSO or GOOGLE-AWS-AUTH 58 | func (aal *AwsAzureLoginAssumer) Type() string { 59 | return "AWS_AZURE_LOGIN" 60 | } 61 | 62 | // inspect for any items on the profile prefixed with "AZURE_" 63 | func (aal *AwsAzureLoginAssumer) ProfileMatchesType(rawProfile *ini.Section, parsedProfile config.SharedConfig) bool { 64 | for _, k := range rawProfile.KeyStrings() { 65 | if strings.HasPrefix(k, "azure_") { 66 | return true 67 | } 68 | } 69 | return false 70 | } 71 | -------------------------------------------------------------------------------- /pkg/console/service_map.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | // ServiceMap maps CLI flags to AWS console URL paths. 4 | // e.g. passing in `-s ec2` will open the console at the ec2/v2 URL. 5 | var ServiceMap = map[string]string{ 6 | "": "console", 7 | "athena": "athena", 8 | "appsync": "appsync", 9 | "c9": "cloud9", 10 | "ce": "cost-management", 11 | "cf": "cloudfront", 12 | "cfn": "cloudformation", 13 | "cloudformation": "cloudformation", 14 | "cloudmap": "cloudmap", 15 | "cloudwatch": "cloudwatch", 16 | "config": "config", 17 | "ct": "cloudtrail", 18 | "cw": "cloudwatch", 19 | "ddb": "dynamodbv2", 20 | "dms": "dms/v2", 21 | "dx": "directconnect/v2", 22 | "eb": "elasticbeanstalk", 23 | "ebs": "elasticbeanstalk", 24 | "ec2": "ec2/v2", 25 | "ecr": "ecr", 26 | "ecs": "ecs", 27 | "eks": "eks", 28 | "gd": "guardduty", 29 | "grafana": "grafana", 30 | "iam": "iamv2", 31 | "l": "lambda", 32 | "lambda": "lambda", 33 | "mwaa": "mwaa", 34 | "param": "systems-manager/parameters", 35 | "r53": "route53/v2", 36 | "rds": "rds", 37 | "redshift": "redshiftv2", 38 | "route53": "route53/v2", 39 | "s3": "s3", 40 | "sagemaker": "sagemaker", 41 | "secretsmanager": "secretsmanager", 42 | "sm": "secretsmanager", 43 | "scrm": "secretsmanager", 44 | "securityhub": "securityhub", 45 | "sfn": "states", 46 | "scrh": "securityhub", 47 | "ses": "ses", 48 | "stepfn": "states", 49 | "ssm": "systems-manager", 50 | "sns": "sns", 51 | "states": "states", 52 | "sso": "singlesignon", 53 | "trustedadvisor": "trustedadvisor", 54 | "tra": "trustedadvisor", 55 | "vpc": "vpc", 56 | "waf": "wafv2", 57 | } 58 | 59 | var globalServiceMap = map[string]bool{ 60 | "dx": true, 61 | "iam": true, 62 | "r53": true, 63 | "route53": true, 64 | "trustedadvisor": true, 65 | } 66 | -------------------------------------------------------------------------------- /pkg/autosync/registry_config.go: -------------------------------------------------------------------------------- 1 | package autosync 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | "path" 8 | "time" 9 | 10 | "github.com/common-fate/clio" 11 | ) 12 | 13 | const ( 14 | FILENAME = "registry-sync" 15 | ) 16 | 17 | type RegistrySyncConfig struct { 18 | dir string 19 | LastCheckForSync time.Weekday `json:"lastCheckForSync"` 20 | } 21 | 22 | // return the absolute path of commonfate/registry-sync file. 23 | func (rc RegistrySyncConfig) Path() string { 24 | return path.Join(rc.dir, FILENAME) 25 | } 26 | 27 | // create or save config in required file path. 28 | func (rc RegistrySyncConfig) Save() error { 29 | if rc.dir == "" { 30 | return errors.New("version config dir was not specified") 31 | } 32 | 33 | err := os.MkdirAll(rc.dir, os.ModePerm) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | data, err := json.Marshal(rc) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | err = os.WriteFile(rc.Path(), data, 0666) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | return nil 49 | } 50 | 51 | // if the required dir is present then os.MkdirAll will return nil 52 | // therefore we will check if 'registry-sync' file is present or not 53 | // if present then unmarshall the file and return the registry sync config 54 | // else log and return 55 | func loadRegistryConfig() (rc RegistrySyncConfig, ok bool) { 56 | cd, err := os.UserConfigDir() 57 | if err != nil { 58 | clio.Debug("error loading user config dir: %s", err.Error()) 59 | return 60 | } 61 | 62 | rc.dir = path.Join(cd, "commonfate") 63 | err = os.MkdirAll(rc.dir, os.ModePerm) 64 | if err != nil { 65 | clio.Debug("error creating commonfate config dir: %s", err.Error()) 66 | return 67 | } 68 | 69 | rcfile := path.Join(rc.dir, FILENAME) 70 | if _, err := os.Stat(rcfile); errors.Is(err, os.ErrNotExist) { 71 | clio.Debug("registry sync config file does not exist: %s", rcfile) 72 | return 73 | } 74 | 75 | data, err := os.ReadFile(rcfile) 76 | if err != nil { 77 | clio.Debug("error reading registry sync config: %s", err.Error()) 78 | return 79 | } 80 | err = json.Unmarshal(data, &rc) 81 | if err != nil { 82 | clio.Debug("error unmarshalling registry sync config: %s", err.Error()) 83 | return 84 | } 85 | ok = true 86 | return 87 | } 88 | -------------------------------------------------------------------------------- /pkg/shells/file.go: -------------------------------------------------------------------------------- 1 | package shells 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | // AppendLine writes a line to a file if it does not already exist 10 | func AppendLine(file string, line string) error { 11 | b, err := os.ReadFile(file) 12 | if err != nil { 13 | return err 14 | } 15 | 16 | // return an error if the file already contains this line 17 | if strings.Contains(string(b), line) { 18 | return &ErrLineAlreadyExists{File: file} 19 | } 20 | 21 | // open the file for writing 22 | out, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY, 0644) 23 | if err != nil { 24 | return err 25 | } 26 | defer out.Close() 27 | // include newlines around the line 28 | a := fmt.Sprintf("\n%s\n", line) 29 | _, err = out.WriteString(a) 30 | if err != nil { 31 | return err 32 | } 33 | return nil 34 | } 35 | 36 | // RemoveLine removes a line from a file if it exists 37 | func RemoveLine(file string, line string) error { 38 | b, err := os.ReadFile(file) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | // the line number in the file where the alias was found 44 | var lineIndex int 45 | var found bool 46 | 47 | var ignored []string 48 | 49 | lines := strings.Split(string(b), "\n") 50 | for i, line := range lines { 51 | removeLine := strings.Contains(line, line) 52 | 53 | // When removing the line, if the line after is empty we 54 | // remove that too. This prevents the length of the config file growing by 1 with blank lines 55 | // every time Granted adds or removes a line. Really only useful as a nice 56 | // convenience for developing the Granted CLI, when we do a lot adding and removing lines. 57 | if found && i == lineIndex+1 && line == "" { 58 | removeLine = true 59 | } 60 | 61 | if !removeLine { 62 | ignored = append(ignored, line) 63 | } else { 64 | // mark that we've found the line in the file 65 | found = true 66 | lineIndex = i 67 | } 68 | } 69 | 70 | if !found { 71 | // we didn't find the line in the file, so return an error in order to let the user know that it doesn't exist there. 72 | return &ErrLineNotFound{File: file} 73 | } 74 | 75 | output := strings.Join(ignored, "\n") 76 | err = os.WriteFile(file, []byte(output), 0644) 77 | if err != nil { 78 | return err 79 | } 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /pkg/testable/testable.go: -------------------------------------------------------------------------------- 1 | package testable 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "testing" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | "github.com/AlecAivazis/survey/v2/core" 10 | ) 11 | 12 | var isTesting = false 13 | var nextSurveyInput func() StringOrBool = func() StringOrBool { panic("not implemented") } 14 | var validateNextOutput func(format string, a ...interface{}) = func(format string, a ...interface{}) { panic("not implemented") } 15 | 16 | // use this type for survey inputs 17 | type StringOrBool interface{} 18 | 19 | // use this type for survey inputs 20 | type SurveyInputs []StringOrBool 21 | 22 | // configures Testable functions to utilise testing hooks 23 | func BeginTesting() { 24 | isTesting = true 25 | } 26 | 27 | // configures Testable functions to stop utilising testing hooks 28 | func EndTesting() { 29 | isTesting = false 30 | } 31 | 32 | // Configure this with a function that returns the next input required for a cli test 33 | func WithNextSurveyInputFunc(next func() StringOrBool) { 34 | nextSurveyInput = next 35 | } 36 | 37 | // A helper which produces a next function that will call t.Fatal if all the inputs are exhausted 38 | // position is an int representing the index in input for the next survey input 39 | func NextFuncFromSlice(t *testing.T, inputs SurveyInputs, position *int) func() StringOrBool { 40 | return func() StringOrBool { 41 | if *position > len(inputs) { 42 | t.Fatal("attempted to call nextSurveyInput when no inputs remain") 43 | } 44 | v := inputs[*position] 45 | i := *position + 1 46 | position = &i 47 | return v 48 | } 49 | } 50 | 51 | // AskOne is a function which can be used to intercept surveys in the cli and replace the survey with input from a test input stream 52 | // NextSurveyInput should be set to a function which returns the next string to satisfy the input 53 | func AskOne(in survey.Prompt, out interface{}, opts ...survey.AskOpt) error { 54 | if isTesting { 55 | return core.WriteAnswer(out, "", nextSurveyInput()) 56 | } 57 | return survey.AskOne(in, out, opts...) 58 | } 59 | 60 | func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) { 61 | if isTesting { 62 | validateNextOutput(format, a...) 63 | return len([]byte(fmt.Sprintf(format, a...))), nil 64 | } 65 | n, err = fmt.Fprintf(w, format, a...) 66 | return 67 | } 68 | -------------------------------------------------------------------------------- /pkg/granted/registry/migrate.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/AlecAivazis/survey/v2" 7 | "github.com/common-fate/clio" 8 | grantedConfig "github.com/common-fate/granted/pkg/config" 9 | "github.com/common-fate/granted/pkg/testable" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | // Profile Registry data structure has been updated to accomodate different registry level options. 14 | // If any user is using the previous configuration then prompt user to update the registry values. 15 | var MigrateCommand = cli.Command{ 16 | Name: "migrate", 17 | Description: "Migrate Profile Registry Configuration", 18 | Usage: "Migrate Profile Registry Configuration", 19 | 20 | Action: func(c *cli.Context) error { 21 | gConf, err := grantedConfig.Load() 22 | if err != nil { 23 | clio.Debug(err.Error()) 24 | } 25 | 26 | if len(gConf.ProfileRegistryURLS) > 0 { 27 | var registries []grantedConfig.Registry 28 | for i, u := range gConf.ProfileRegistryURLS { 29 | var msg survey.Input 30 | if i > 0 { 31 | msg = survey.Input{Message: fmt.Sprintf("Enter a registry name for %s", u), Default: fmt.Sprintf("granted-registry-%d", i)} 32 | } else { 33 | msg = survey.Input{Message: fmt.Sprintf("Enter a registry name for %s", u), Default: "granted-registry"} 34 | } 35 | 36 | var selected string 37 | err := testable.AskOne(&msg, &selected) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | registries = append(registries, grantedConfig.Registry{ 43 | Name: selected, 44 | URL: u, 45 | }) 46 | 47 | } 48 | 49 | gConf.ProfileRegistry.Registries = registries 50 | gConf.ProfileRegistryURLS = nil 51 | 52 | err = gConf.Save() 53 | if err != nil { 54 | clio.Debug(err.Error()) 55 | return err 56 | } 57 | 58 | clio.Success("Successfully migrated your configuration.") 59 | 60 | return nil 61 | } 62 | 63 | clio.Infof("Your Profile Registry already has the latest configuration. No action required.") 64 | 65 | return nil 66 | }, 67 | } 68 | 69 | func IsOutdatedConfig() bool { 70 | gConf, err := grantedConfig.Load() 71 | if err != nil { 72 | clio.Debug(err.Error()) 73 | return true 74 | } 75 | 76 | if len(gConf.ProfileRegistryURLS) > 0 { 77 | return true 78 | } 79 | 80 | return false 81 | } 82 | -------------------------------------------------------------------------------- /scripts/assume.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | set SHELL=cmd 3 | 4 | set GRANTED_ALIAS_CONFIGURED=true 5 | assumego %* 1> %TEMP%\temp-assume.txt 6 | set ASSUME_STATUS=%ERRORLEVEL% 7 | set /p ASSUME_OUTPUT=<%TEMP%\temp-assume.txt 8 | del %TEMP%\temp-assume.txt 9 | 10 | @echo off 11 | for /f "tokens=1,2,3,4,5,6,7,8,9,10,11 delims= " %%a in ("%ASSUME_OUTPUT%") do ( 12 | 13 | if "%%a" == "GrantedDesume" ( 14 | set AWS_ACCESS_KEY_ID= 15 | set AWS_SECRET_ACCESS_KEY= 16 | set AWS_SESSION_TOKEN= 17 | set AWS_PROFILE= 18 | set AWS_REGION= 19 | set AWS_SESSION_EXPIRATION= 20 | set AWS_CREDENTIAL_EXPIRATION= 21 | 22 | set GRANTED_SSO= 23 | set GRANTED_SSO_START_URL= 24 | set GRANTED_SSO_ROLE_NAME= 25 | set GRANTED_SSO_REGION= 26 | set GRANTED_SSO_ACCOUNT_ID= 27 | Exit /b %ASSUME_STATUS% 28 | ) 29 | 30 | if "%%a" == "GrantedAssume" ( 31 | set AWS_ACCESS_KEY_ID= 32 | set AWS_SECRET_ACCESS_KEY= 33 | set AWS_SESSION_TOKEN= 34 | set AWS_PROFILE= 35 | set AWS_REGION= 36 | set AWS_SESSION_EXPIRATION= 37 | set AWS_CREDENTIAL_EXPIRATION= 38 | 39 | set GRANTED_SSO= 40 | set GRANTED_SSO_START_URL= 41 | set GRANTED_SSO_ROLE_NAME= 42 | set GRANTED_SSO_REGION= 43 | set GRANTED_SSO_ACCOUNT_ID= 44 | 45 | if "%%b" NEQ "None" ( 46 | set AWS_ACCESS_KEY_ID=%%b) 47 | 48 | if "%%c" NEQ "None" ( 49 | set AWS_SECRET_ACCESS_KEY=%%c) 50 | 51 | if "%%d" NEQ "None" ( 52 | set AWS_SESSION_TOKEN=%%d) 53 | 54 | if "%%e" NEQ "None" ( 55 | set AWS_PROFILE=%%e) 56 | 57 | if "%%f" NEQ "None" ( 58 | set AWS_REGION=%%f) 59 | 60 | if "%%g" NEQ "None" ( 61 | set AWS_SESSION_EXPIRATION=%%g 62 | set AWS_CREDENTIAL_EXPIRATION=%%g) 63 | 64 | if "%%h" NEQ "None" ( 65 | set GRANTED_SSO=%%g) 66 | 67 | if "%%i" NEQ "None" ( 68 | set GRANTED_SSO_START_URL=%%g) 69 | 70 | if "%%j" NEQ "None" ( 71 | set GRANTED_SSO_ROLE_NAME=%%g) 72 | 73 | if "%%k" NEQ "None" ( 74 | set GRANTED_SSO_REGION=%%g) 75 | 76 | if "%%l" NEQ "None" ( 77 | set GRANTED_SSO_ACCOUNT_ID=%%g) 78 | 79 | Exit /b %ASSUME_STATUS% 80 | ) 81 | 82 | 83 | echo %ASSUME_OUTPUT% 84 | ) 85 | -------------------------------------------------------------------------------- /pkg/cfaws/assumers.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | "github.com/aws/aws-sdk-go-v2/config" 8 | "gopkg.in/ini.v1" 9 | ) 10 | 11 | // Added support for optional pass through args on proxy sso provider 12 | // When using a sso provider adding pass through flags can be achieved by adding the -pass-through or -pt flag 13 | // EG. assume role-a -pt --mode -pt gui (Run the proxy login with a gui rather than in cli. Example taken from aws-azure-login) 14 | type Assumer interface { 15 | // AssumeTerminal should follow the required process for it implemetation and return aws credentials ready to be exported to the terminal environment 16 | AssumeTerminal(context.Context, *Profile, ConfigOpts) (aws.Credentials, error) 17 | // AssumeConsole should follow any console specific credentials processes, this may be the same as AssumeTerminal under the hood 18 | AssumeConsole(context.Context, *Profile, ConfigOpts) (aws.Credentials, error) 19 | // A unique key which identifies this assumer e.g AWS-SSO or GOOGLE-AWS-AUTH 20 | Type() string 21 | // ProfileMatchesType takes a list of strings which are the lines in an aws config profile and returns true if this profile is the assumers type 22 | ProfileMatchesType(*ini.Section, config.SharedConfig) bool 23 | } 24 | 25 | // List of assumers should be ordered by how they match type 26 | // specific types should be first, generic types like IAM should be last / the (default) 27 | // for sso profiles, the internal implementation takes precedence over credential processes 28 | var assumers []Assumer = []Assumer{&AwsGoogleAuthAssumer{}, &AwsAzureLoginAssumer{}, &AwsSsoAssumer{}, &CredentialProcessAssumer{}, &AwsIamAssumer{}, &AwsIamMfaAssumer{}} 29 | 30 | // RegisterAssumer allows assumers to be registered when using this library as a package in other projects 31 | // position = -1 will append the assumer 32 | // position to insert assumer 33 | func RegisterAssumer(a Assumer, position int) { 34 | if position < 0 || position > len(assumers)-1 { 35 | assumers = append(assumers, a) 36 | } else { 37 | newAssumers := append([]Assumer{}, assumers[:position]...) 38 | newAssumers = append(newAssumers, a) 39 | assumers = append(newAssumers, assumers[position:]...) 40 | } 41 | } 42 | 43 | func AssumerFromType(t string) Assumer { 44 | for _, a := range assumers { 45 | if a.Type() == t { 46 | return a 47 | } 48 | } 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /pkg/granted/registry/git.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/common-fate/clio" 10 | ) 11 | 12 | func gitPull(repoDirPath string, shouldSilentLogs bool) error { 13 | // pull the repo here. 14 | clio.Debugf("git -C %s pull %s %s\n", repoDirPath, "origin", "HEAD") 15 | cmd := exec.Command("git", "-C", repoDirPath, "pull", "origin", "HEAD") 16 | 17 | // StderrPipe returns a pipe that will be connected to the command's 18 | // standard error when the command starts. 19 | stderr, _ := cmd.StderrPipe() 20 | if err := cmd.Start(); err != nil { 21 | return err 22 | } 23 | 24 | scanner := bufio.NewScanner(stderr) 25 | for scanner.Scan() { 26 | if strings.Contains(scanner.Text(), "error") || strings.Contains(scanner.Text(), "fatal") { 27 | return fmt.Errorf(scanner.Text()) 28 | } 29 | 30 | if shouldSilentLogs { 31 | clio.Debug(scanner.Text()) 32 | } else { 33 | clio.Info(scanner.Text()) 34 | } 35 | } 36 | 37 | clio.Debugf("Successfully pulled the repo") 38 | 39 | return nil 40 | } 41 | 42 | func gitInit(repoDirPath string) error { 43 | clio.Debugf("git init %s\n", repoDirPath) 44 | 45 | cmd := exec.Command("git", "init", repoDirPath) 46 | 47 | err := cmd.Run() 48 | if err != nil { 49 | return err 50 | 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func gitClone(repoURL string, repoDirPath string) error { 57 | clio.Debugf("git clone %s\n", repoURL) 58 | 59 | cmd := exec.Command("git", "clone", repoURL, repoDirPath) 60 | 61 | stderr, _ := cmd.StderrPipe() 62 | if err := cmd.Start(); err != nil { 63 | return err 64 | } 65 | 66 | scanner := bufio.NewScanner(stderr) 67 | for scanner.Scan() { 68 | if strings.Contains(strings.ToLower(scanner.Text()), "error") || strings.Contains(strings.ToLower(scanner.Text()), "fatal") { 69 | return fmt.Errorf(scanner.Text()) 70 | } 71 | 72 | clio.Info(scanner.Text()) 73 | } 74 | clio.Debugf("Successfully cloned %s", repoURL) 75 | 76 | return nil 77 | } 78 | 79 | // set the path of the repo before checking out 80 | // if a specific ref is passed we will checkout that ref 81 | // can be a git hash, tag, or branch name. In that order 82 | func CheckoutRef(ref string, repoDirPath string) error { 83 | 84 | cmd := exec.Command("git", "checkout", ref) 85 | cmd.Dir = repoDirPath 86 | 87 | err := cmd.Run() 88 | if err != nil { 89 | return err 90 | } 91 | clio.Debugf("Sucessfully checkout out " + ref) 92 | return nil 93 | 94 | } 95 | -------------------------------------------------------------------------------- /pkg/forkprocess/forkprocess.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | // Package forkprocess starts a process which runs in the background. 4 | // In Granted we use it to launch a browser when the user requests a web console. 5 | // Previously, we used exec.Command from Go's stdlib for this, but is susceptible 6 | // to being closed when the user pressed CTRL+C in their terminal. 7 | // 8 | // Thanks to @patricksanders for the advice here. 9 | // The github.com/ik5/fork_process package is also a good 10 | // reference we'd like to acknowledge. 11 | package forkprocess 12 | 13 | import ( 14 | "os" 15 | "os/user" 16 | "strconv" 17 | "syscall" 18 | 19 | "github.com/pkg/errors" 20 | ) 21 | 22 | type Process struct { 23 | UID uint32 24 | GID uint32 25 | Args []string 26 | Workdir string 27 | } 28 | 29 | // New creates a new Process with the current user's user and group ID. 30 | // Call Start() on the returned process to actually start it. 31 | func New(args ...string) (*Process, error) { 32 | u, err := user.Current() 33 | if err != nil { 34 | return nil, errors.Wrap(err, "getting current user") 35 | } 36 | uid, err := strconv.ParseUint(u.Uid, 10, 32) 37 | if err != nil { 38 | return nil, errors.Wrapf(err, "parsing uid (%s)", u.Uid) 39 | } 40 | gid, err := strconv.ParseUint(u.Gid, 10, 32) 41 | if err != nil { 42 | return nil, errors.Wrapf(err, "parsing gid (%s)", u.Uid) 43 | } 44 | 45 | p := Process{ 46 | UID: uint32(uid), 47 | GID: uint32(gid), 48 | Args: args, 49 | } 50 | return &p, nil 51 | } 52 | 53 | // Start launches a detached process under the current user and group ID. 54 | func (p *Process) Start() error { 55 | var cred = &syscall.Credential{ 56 | Uid: p.UID, 57 | Gid: p.GID, 58 | NoSetGroups: true, 59 | } 60 | 61 | var sysproc = &syscall.SysProcAttr{ 62 | Credential: cred, 63 | Setsid: true, 64 | } 65 | 66 | rpipe, wpipe, err := os.Pipe() 67 | if err != nil { 68 | return errors.Wrap(err, "getting read and write files") 69 | } 70 | defer rpipe.Close() 71 | defer wpipe.Close() 72 | 73 | attr := os.ProcAttr{ 74 | Dir: p.Workdir, 75 | Env: os.Environ(), 76 | Files: []*os.File{ 77 | rpipe, 78 | wpipe, 79 | wpipe, 80 | }, 81 | Sys: sysproc, 82 | } 83 | process, err := os.StartProcess(p.Args[0], p.Args, &attr) 84 | if err != nil { 85 | return errors.Wrap(err, "starting process") 86 | } 87 | 88 | err = process.Release() 89 | if err != nil { 90 | return errors.Wrap(err, "releasing process") 91 | } 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /pkg/console/partition.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import "strings" 4 | 5 | type PartitionHost int 6 | 7 | const ( 8 | Default PartitionHost = iota 9 | Gov 10 | Cn 11 | ISO 12 | ISOB 13 | ) 14 | 15 | func (p PartitionHost) String() string { 16 | switch p { 17 | case Default: 18 | return "aws" 19 | case Gov: 20 | return "aws-us-gov" 21 | case Cn: 22 | return "aws-cn" 23 | case ISO: 24 | return "aws-iso" 25 | case ISOB: 26 | return "aws-iso-b" 27 | } 28 | return "aws" 29 | } 30 | 31 | func (p PartitionHost) HostString() string { 32 | return p.RegionalHostString("") 33 | } 34 | 35 | func (p PartitionHost) RegionalHostString(region string) string { 36 | regionPrefix := GetRegionPrefixFromRegion(region) 37 | switch p { 38 | case Default: 39 | return regionPrefix + "signin.aws.amazon.com" 40 | case Gov: 41 | return regionPrefix + "signin.amazonaws-us-gov.com" 42 | case Cn: 43 | return regionPrefix + "signin.amazonaws.cn" 44 | } 45 | 46 | // Note: we're not handling the ISO and ISOB cases, I don't think they are supported by a public AWS console 47 | return regionPrefix + "signin.aws.amazon.com" 48 | } 49 | 50 | func (p PartitionHost) ConsoleHostString() string { 51 | return p.RegionalConsoleHostString("") 52 | } 53 | 54 | func (p PartitionHost) RegionalConsoleHostString(region string) string { 55 | regionPrefix := GetRegionPrefixFromRegion(region) 56 | switch p { 57 | case Default: 58 | return "https://" + regionPrefix + "console.aws.amazon.com/" 59 | case Gov: 60 | return "https://" + regionPrefix + "console.amazonaws-us-gov.com/" 61 | case Cn: 62 | return "https://" + regionPrefix + "console.amazonaws.cn/" 63 | } 64 | // Note: we're not handling the ISO and ISOB cases, I don't think they are supported by a public AWS console 65 | return "https://" + regionPrefix + "console.aws.amazon.com/" 66 | } 67 | 68 | func GetPartitionFromRegion(region string) PartitionHost { 69 | partition := strings.Split(region, "-") 70 | if partition[0] == "cn" { 71 | return PartitionHost(Cn) 72 | } 73 | if len(partition) > 1 { 74 | if partition[1] == "iso" { 75 | return PartitionHost(ISO) 76 | } 77 | if partition[1] == "isob" { 78 | return PartitionHost(ISOB) 79 | } 80 | if partition[1] == "gov" { 81 | return PartitionHost(Gov) 82 | } 83 | } 84 | return PartitionHost(Default) 85 | } 86 | 87 | func GetRegionPrefixFromRegion(region string) string { 88 | if region == "us-east-1" || region == "" || region == "cn-north-1" || region == "us-gov-west-1" { 89 | return "" 90 | } else { 91 | return region + "." 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /pkg/cfaws/creds.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/AlecAivazis/survey/v2" 10 | "github.com/aws/aws-sdk-go-v2/aws" 11 | ssotypes "github.com/aws/aws-sdk-go-v2/service/sso/types" 12 | "github.com/aws/aws-sdk-go-v2/service/sts" 13 | "github.com/aws/aws-sdk-go-v2/service/sts/types" 14 | "github.com/common-fate/granted/pkg/testable" 15 | ) 16 | 17 | func TypeCredsToAwsCreds(c types.Credentials) aws.Credentials { 18 | return aws.Credentials{AccessKeyID: *c.AccessKeyId, SecretAccessKey: *c.SecretAccessKey, SessionToken: *c.SessionToken, CanExpire: true, Expires: *c.Expiration} 19 | } 20 | func TypeRoleCredsToAwsCreds(c ssotypes.RoleCredentials) aws.Credentials { 21 | return aws.Credentials{AccessKeyID: *c.AccessKeyId, SecretAccessKey: *c.SecretAccessKey, SessionToken: *c.SessionToken, CanExpire: true, Expires: time.UnixMilli(c.Expiration)} 22 | } 23 | 24 | // CredProv implements the aws.CredentialProvider interface 25 | type CredProv struct{ aws.Credentials } 26 | 27 | func (c *CredProv) Retrieve(ctx context.Context) (aws.Credentials, error) { 28 | return c.Credentials, nil 29 | } 30 | 31 | // loads the environment variables and hydrates an aws.config if they are present 32 | func GetEnvCredentials(ctx context.Context) aws.Credentials { 33 | return aws.Credentials{AccessKeyID: os.Getenv("AWS_ACCESS_KEY_ID"), SecretAccessKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), SessionToken: os.Getenv("AWS_SESSION_TOKEN")} 34 | } 35 | 36 | func GetCredentialsCreds(ctx context.Context, c *Profile) (aws.Credentials, error) { 37 | // check to see if the creds are already exported 38 | creds, _ := aws.NewCredentialsCache(&CredProv{Credentials: c.AWSConfig.Credentials}).Retrieve(ctx) 39 | 40 | // check creds are valid - return them if they are 41 | if creds.HasKeys() && !creds.Expired() { 42 | cfg := aws.NewConfig() 43 | cfg.Credentials = &CredProv{Credentials: c.AWSConfig.Credentials} 44 | client := sts.NewFromConfig(cfg.Copy()) 45 | // the AWS SDK check for credential expiry doesn't actually check some credentials so we do this sts call to validate it 46 | _, err := client.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) 47 | if err == nil { 48 | return creds, nil 49 | } 50 | } 51 | return aws.Credentials{}, fmt.Errorf("creds invalid or expired") 52 | 53 | } 54 | 55 | func MfaTokenProvider() (string, error) { 56 | in := survey.Input{Message: "MFA Token"} 57 | var out string 58 | withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) 59 | err := testable.AskOne(&in, &out, withStdio) 60 | return out, err 61 | } 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

Granted

4 |

The easiest way to access your cloud.

5 | 6 |

7 | tweet 8 | slack 9 |

10 | 11 |

12 | 🚀 Get Started 13 |

14 | 15 |

16 | 17 | 18 | 19 |

20 | 21 |
22 | 23 | ## What is Granted? 24 | 25 | Granted is a command line interface (CLI) application which simplifies access to cloud roles and allows multiple cloud accounts to be opened in your web browser simultaneously. The goals of Granted are: 26 | 27 | - Provide a fast experience around finding and assuming roles 28 | 29 | - Leverage native browser functionality to allow multiple accounts to be accessed at once 30 | 31 | - Encrypt cached credentials to avoid plaintext SSO tokens being saved on disk 32 | 33 | ## What does Granted work with? 34 | 35 | Granted supports MacOS, Linux, and Windows. Our Windows support is less extensively tested than other platforms so if you run into any problems please [let us know](https://join.slack.com/t/commonfatecommunity/shared_invite/zt-q4m96ypu-_gYlRWD3k5rIsaSsqP7QMg). 36 | 37 | Currently Granted supports accessing roles in AWS. If you'd like to see support for another cloud provider please [open an issue](https://github.com/common-fate/granted/issues/new)! 38 | 39 | ## Documentation 40 | 41 | Get started by [reading our documentation](https://docs.commonfate.io/granted/getting-started). 42 | 43 | ## Contributing 44 | 45 | See [CONTRIBUTING.md](./CONTRIBUTING.md) for information on how to contribute. We welcome all contributors - [join our Slack](https://join.slack.com/t/commonfatecommunity/shared_invite/zt-q4m96ypu-_gYlRWD3k5rIsaSsqP7QMg) to discuss the project! 46 | 47 | ## Security 48 | 49 | See [SECURITY.md](./SECURITY.md) for security information. You can view our full security documentation on the [Granted website](https://docs.commonfate.io/granted/security). 50 | -------------------------------------------------------------------------------- /pkg/granted/registry/remove.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/AlecAivazis/survey/v2" 7 | "github.com/common-fate/clio" 8 | grantedConfig "github.com/common-fate/granted/pkg/config" 9 | "github.com/common-fate/granted/pkg/testable" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | var RemoveCommand = cli.Command{ 14 | Name: "remove", 15 | Description: "Unsubscribe from a Profile Registry", 16 | Usage: "Unsubscribe from a Profile Registry", 17 | 18 | Action: func(c *cli.Context) error { 19 | gConf, err := grantedConfig.Load() 20 | if err != nil { 21 | return err 22 | } 23 | 24 | if len(gConf.ProfileRegistry.Registries) == 0 { 25 | clio.Error("There are no profile registries configured currently.\n Please use 'granted registry add ' to add a new registry") 26 | return nil 27 | } 28 | 29 | registriesWithNames := []string{} 30 | 31 | for _, r := range gConf.ProfileRegistry.Registries { 32 | registriesWithNames = append(registriesWithNames, r.Name) 33 | } 34 | 35 | in := survey.Select{Message: "Please select the git repository you would like to unsubscribe:", Options: registriesWithNames} 36 | var out string 37 | err = testable.AskOne(&in, &out) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | var selectedRegistry grantedConfig.Registry 43 | 44 | for _, r := range gConf.ProfileRegistry.Registries { 45 | if r.Name == out { 46 | selectedRegistry = r 47 | } 48 | } 49 | 50 | repoDir, err := getRegistryLocation(selectedRegistry) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | err = removeAutogeneratedProfileByName(out) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | err = os.RemoveAll(repoDir) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | err = remove(gConf, out) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | err = gConf.Save() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | clio.Successf("Successfully unsubscribed from %s", out) 76 | 77 | return nil 78 | }, 79 | } 80 | 81 | func remove(gConf *grantedConfig.Config, rName string) error { 82 | registries := gConf.ProfileRegistry.Registries 83 | 84 | var index int = -1 85 | for i := 0; i < len(registries); i++ { 86 | if registries[i].Name == rName { 87 | index = i 88 | } 89 | } 90 | 91 | if index > -1 { 92 | registries = append(registries[:index], registries[index+1:]...) 93 | } 94 | 95 | gConf.ProfileRegistry.Registries = registries 96 | 97 | err := gConf.Save() 98 | if err != nil { 99 | return err 100 | } 101 | 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /pkg/granted/entrypoint.go: -------------------------------------------------------------------------------- 1 | package granted 2 | 3 | import ( 4 | "github.com/common-fate/cli/cmd/command" 5 | "github.com/common-fate/clio" 6 | "github.com/common-fate/granted/internal/build" 7 | "github.com/common-fate/granted/pkg/banners" 8 | "github.com/common-fate/granted/pkg/config" 9 | "github.com/common-fate/granted/pkg/granted/exp" 10 | "github.com/common-fate/granted/pkg/granted/middleware" 11 | "github.com/common-fate/granted/pkg/granted/registry" 12 | "github.com/common-fate/granted/pkg/granted/settings" 13 | "github.com/common-fate/granted/pkg/securestorage" 14 | "github.com/common-fate/useragent" 15 | "github.com/pkg/errors" 16 | "github.com/urfave/cli/v2" 17 | "go.uber.org/zap" 18 | ) 19 | 20 | func GetCliApp() *cli.App { 21 | cli.VersionPrinter = func(c *cli.Context) { 22 | clio.Log(banners.WithVersion(banners.Granted())) 23 | } 24 | 25 | flags := []cli.Flag{ 26 | &cli.BoolFlag{Name: "verbose", Usage: "Log debug messages"}, 27 | &cli.StringFlag{Name: "update-checker-api-url", Value: build.UpdateCheckerApiUrl, EnvVars: []string{"UPDATE_CHECKER_API_URL"}, Hidden: true}, 28 | } 29 | 30 | app := &cli.App{ 31 | Flags: flags, 32 | Name: "granted", 33 | Usage: "https://granted.dev", 34 | UsageText: "granted [global options] command [command options] [arguments...]", 35 | Version: build.Version, 36 | HideVersion: false, 37 | Commands: []*cli.Command{ 38 | &DefaultBrowserCommand, 39 | &settings.SettingsCommand, 40 | &CompletionCommand, 41 | &TokenCommand, 42 | &SSOTokensCommand, 43 | &UninstallCommand, 44 | &SSOCommand, 45 | &CredentialsCommand, 46 | middleware.WithBeforeFuncs(&CredentialProcess, middleware.WithAutosync()), 47 | ®istry.ProfileRegistryCommand, 48 | &ConsoleCommand, 49 | &login, 50 | &exp.Command, 51 | }, 52 | EnableBashCompletion: true, 53 | Before: func(c *cli.Context) error { 54 | clio.SetLevelFromEnv("GRANTED_LOG") 55 | zap.ReplaceGlobals(clio.G()) 56 | if c.Bool("verbose") { 57 | clio.SetLevelFromString("debug") 58 | } 59 | if err := config.SetupConfigFolder(); err != nil { 60 | return err 61 | } 62 | // set the user agent 63 | c.Context = useragent.NewContext(c.Context, "granted", build.Version) 64 | 65 | return nil 66 | }, 67 | } 68 | 69 | return app 70 | } 71 | 72 | var login = cli.Command{ 73 | Name: "login", 74 | Usage: "Log in to Common Fate", 75 | Action: func(c *cli.Context) error { 76 | k, err := securestorage.NewCF().Storage.Keyring() 77 | if err != nil { 78 | return errors.Wrap(err, "loading keyring") 79 | } 80 | 81 | // wrap the nested CLI command with the keyring 82 | lf := command.LoginFlow{Keyring: k} 83 | 84 | return lf.LoginAction(c) 85 | }, 86 | } 87 | -------------------------------------------------------------------------------- /pkg/cfaws/frecent_profiles.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "github.com/common-fate/clio" 5 | "github.com/common-fate/granted/pkg/frecency" 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | var frecencyStoreKey = "aws_profiles_frecency" 10 | 11 | type FrecentProfiles struct { 12 | store *frecency.FrecencyStore 13 | toRemove []string 14 | } 15 | 16 | // should be called after selecting a profile to update frecency cache 17 | // wrap this method in a go routine to avoid blocking the user 18 | func (f *FrecentProfiles) Update(selectedProfile string) { 19 | s := make([]interface{}, len(f.toRemove)) 20 | for i, v := range f.toRemove { 21 | s[i] = v 22 | } 23 | err := f.store.DeleteAll(s) 24 | if err != nil { 25 | clio.Debug(errors.Wrap(err, "removing entries from frecency").Error()) 26 | } 27 | err = f.store.Upsert(selectedProfile) 28 | if err != nil { 29 | clio.Debug(errors.Wrap(err, "upserting entry to frecency").Error()) 30 | } 31 | } 32 | 33 | // use this to update frecency cache when the profile is supplied by the commandline 34 | func UpdateFrecencyCache(selectedProfile string) { 35 | fr, err := frecency.Load(frecencyStoreKey) 36 | if err != nil { 37 | clio.Debug(errors.Wrap(err, "loading aws_profiles_frecency frecency store").Error()) 38 | } else { 39 | err = fr.Upsert(selectedProfile) 40 | if err != nil { 41 | clio.Debug(errors.Wrap(err, "upserting entry to frecency").Error()) 42 | } 43 | } 44 | } 45 | 46 | // loads the frecency cache and generates a list of profiles with frecently used profiles first, followed by alphabetically sorted profiles that have not been used with assume 47 | // this method returns a FrecentProfiles pointer which should be used after selecting a profile to update the cache, it will also remove any entries which no longer exist in the aws config 48 | func (p *Profiles) GetFrecentProfiles() (*FrecentProfiles, []string) { 49 | names := []string{} 50 | namesMap := make(map[string]string) 51 | fr, err := frecency.Load(frecencyStoreKey) 52 | if err != nil { 53 | clio.Debug(errors.Wrap(err, "loading aws_profiles_frecency frecency store").Error()) 54 | } 55 | namesToRemoveFromFrecency := []string{} 56 | 57 | // add all frecent profile names in order if they are still present in the profileNames slice 58 | for _, entry := range fr.Entries { 59 | e := entry.Entry.(string) 60 | if p.HasProfile(e) { 61 | names = append(names, e) 62 | namesMap[e] = e 63 | } else { 64 | namesToRemoveFromFrecency = append(namesToRemoveFromFrecency, e) 65 | } 66 | } 67 | 68 | // add all other entries from profileNames, sort them alphabetically first 69 | for _, n := range p.ProfileNames { 70 | if _, ok := namesMap[n]; !ok { 71 | names = append(names, n) 72 | } 73 | } 74 | frPr := &FrecentProfiles{store: fr, toRemove: namesToRemoveFromFrecency} 75 | 76 | return frPr, names 77 | } 78 | -------------------------------------------------------------------------------- /scripts/assume.fish: -------------------------------------------------------------------------------- 1 | #!/bin/fish 2 | 3 | #this is set to true because the alias will be configured to point to the fish script in a previous step 4 | #this happens in the assume script 5 | set -gx GRANTED_ALIAS_CONFIGURED "true" 6 | 7 | #GRANTED_FLAG - what granted told the shell to do 8 | #GRANTED_n - the data from granted 9 | 10 | set GRANTED_OUTPUT (assumego $argv) 11 | set GRANTED_STATUS $status 12 | echo $GRANTED_OUTPUT | IFS=' ' read GRANTED_FLAG GRANTED_1 GRANTED_2 GRANTED_3 GRANTED_4 GRANTED_5 GRANTED_6 GRANTED_7 GRANTED_8 GRANTED_9 GRANTED_10 GRANTED_11 13 | 14 | 15 | # remove carriage return 16 | set -gx GRANTED_FLAG (echo $GRANTED_FLAG | tr -d '\r') 17 | 18 | if test "$GRANTED_FLAG" = "NAME:" 19 | assumego $argv 20 | else if test "$GRANTED_FLAG" = "GrantedDesume" 21 | set -e AWS_ACCESS_KEY_ID 22 | set -e AWS_SECRET_ACCESS_KEY 23 | set -e AWS_SESSION_TOKEN 24 | set -e AWS_PROFILE 25 | set -e AWS_REGION 26 | set -e AWS_SESSION_EXPIRATION 27 | set -e AWS_CREDENTIAL_EXPIRATION 28 | set -e GRANTED_SSO 29 | set -e GRANTED_SSO_START_URL 30 | set -e GRANTED_SSO_ROLE_NAME 31 | set -e GRANTED_SSO_REGION 32 | set -e GRANTED_SSO_ACCOUNT_ID 33 | else if test "$GRANTED_FLAG" = "GrantedAssume" 34 | set -e AWS_ACCESS_KEY_ID 35 | set -e AWS_SECRET_ACCESS_KEY 36 | set -e AWS_SESSION_TOKEN 37 | set -e AWS_PROFILE 38 | set -e AWS_REGION 39 | set -e AWS_SESSION_EXPIRATION 40 | set -e AWS_CREDENTIAL_EXPIRATION 41 | set -e GRANTED_SSO 42 | set -e GRANTED_SSO_START_URL 43 | set -e GRANTED_SSO_ROLE_NAME 44 | set -e GRANTED_SSO_REGION 45 | set -e GRANTED_SSO_ACCOUNT_ID 46 | 47 | set -gx GRANTED_COMMAND $argv 48 | if test "$GRANTED_1" != "None" 49 | set -gx AWS_ACCESS_KEY_ID $GRANTED_1 50 | end 51 | if test "$GRANTED_2" != "None" 52 | set -gx AWS_SECRET_ACCESS_KEY $GRANTED_2 53 | end 54 | if test "$GRANTED_3" != "None" 55 | set -gx AWS_SESSION_TOKEN $GRANTED_3 56 | end 57 | if test "$GRANTED_4" != "None" 58 | set -gx AWS_PROFILE $GRANTED_4 59 | end 60 | if test "$GRANTED_5" != "None" 61 | set -gx AWS_REGION $GRANTED_5 62 | end 63 | if test "$GRANTED_6" != "None" 64 | set -gx AWS_SESSION_EXPIRATION $GRANTED_6 65 | set -gx AWS_CREDENTIAL_EXPIRATION $GRANTED_6 66 | end 67 | if test "$GRANTED_7" != "None" 68 | set -gx GRANTED_SSO $GRANTED_7 69 | end 70 | if test "$GRANTED_8" != "None" 71 | set -gx GRANTED_SSO_START_URL $GRANTED_8 72 | end 73 | if test "$GRANTED_9" != "None" 74 | set -gx GRANTED_SSO_ROLE_NAME $GRANTED_9 75 | end 76 | if test "$GRANTED_10" != "None" 77 | set -gx GRANTED_SSO_REGION $GRANTED_10 78 | end 79 | if test "$GRANTED_11" != "None" 80 | set -gx GRANTED_SSO_ACCOUNT_ID $GRANTED_11 81 | end 82 | 83 | else if test "$GRANTED_FLAG" = "GrantedOutput" 84 | for line in $GRANTED_OUTPUT 85 | if test "$line" != "GrantedOutput" 86 | echo $line 87 | end 88 | end 89 | end 90 | 91 | exit $GRANTED_STATUS -------------------------------------------------------------------------------- /pkg/assume/sso.go: -------------------------------------------------------------------------------- 1 | package assume 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | 7 | "github.com/aws/aws-sdk-go-v2/config" 8 | "github.com/common-fate/granted/pkg/cfaws" 9 | cfflags "github.com/common-fate/granted/pkg/urfav_overrides" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | // SSOProfileFromFlags will prepare a profile to be assumed from cli flags 14 | func SSOProfileFromFlags(c *cli.Context) (*cfaws.Profile, error) { 15 | err := ValidateSSOFlags(c) 16 | if err != nil { 17 | return nil, err 18 | } 19 | s := &cfaws.AwsSsoAssumer{} 20 | ssoStartURL, ssoRegion, accountID, roleName := ssoFlags(c) 21 | p := &cfaws.Profile{ 22 | Name: roleName, 23 | ProfileType: s.Type(), 24 | AWSConfig: config.SharedConfig{ 25 | SSOAccountID: accountID, 26 | SSORoleName: roleName, 27 | SSORegion: ssoRegion, 28 | SSOStartURL: ssoStartURL, 29 | }, 30 | Initialised: true, 31 | } 32 | return p, nil 33 | } 34 | 35 | // SSOProfileFromEnv will prepare a profile to be assumed from environment variables 36 | func SSOProfileFromEnv() (*cfaws.Profile, error) { 37 | ssoStartURL := os.Getenv("GRANTED_SSO_START_URL") 38 | ssoRegion := os.Getenv("GRANTED_SSO_REGION") 39 | accountID := os.Getenv("GRANTED_SSO_ACCOUNT_ID") 40 | roleName := os.Getenv("GRANTED_SSO_ROLE_NAME") 41 | if ssoStartURL == "" || ssoRegion == "" || accountID == "" || roleName == "" { 42 | return nil, errors.New("one of the require environment variables was not found while loading an sso profile ['GRANTED_SSO_START_URL','GRANTED_SSO_REGION','GRANTED_SSO_ACCOUNT_ID','GRANTED_SSO_ROLE_NAME']") 43 | } 44 | s := &cfaws.AwsSsoAssumer{} 45 | p := &cfaws.Profile{ 46 | Name: roleName, 47 | ProfileType: s.Type(), 48 | AWSConfig: config.SharedConfig{ 49 | SSOAccountID: accountID, 50 | SSORoleName: roleName, 51 | SSORegion: ssoRegion, 52 | SSOStartURL: ssoStartURL, 53 | }, 54 | Initialised: true, 55 | } 56 | return p, nil 57 | } 58 | 59 | func ssoFlags(c *cli.Context) (ssoStartURL, ssoRegion, accountID, roleName string) { 60 | assumeFlags, err := cfflags.New("assumeFlags", GlobalFlags(), c) 61 | if err != nil { 62 | return 63 | } 64 | ssoStartURL = assumeFlags.String("sso-start-url") 65 | ssoRegion = assumeFlags.String("sso-region") 66 | accountID = assumeFlags.String("account-id") 67 | roleName = assumeFlags.String("role-name") 68 | return 69 | } 70 | func ValidateSSOFlags(c *cli.Context) error { 71 | ssoStartURL, ssoRegion, accountID, roleName := ssoFlags(c) 72 | if c.Bool("sso") { 73 | good := ssoStartURL != "" && ssoRegion != "" && accountID != "" && roleName != "" 74 | if !good { 75 | return errors.New("flags [sso-start-url, sso-region, account-id, role-name] are required to use the -sso flag") 76 | } 77 | } else if ssoStartURL != "" || ssoRegion != "" || accountID != "" || roleName != "" { 78 | return errors.New("flags [sso-start-url, sso-region, account-id, role-name] can only be used with the -sso flag") 79 | } 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /pkg/granted/browser.go: -------------------------------------------------------------------------------- 1 | package granted 2 | 3 | import ( 4 | "github.com/common-fate/clio" 5 | "github.com/common-fate/granted/pkg/browser" 6 | "github.com/common-fate/granted/pkg/config" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | var DefaultBrowserCommand = cli.Command{ 11 | Name: "browser", 12 | Usage: "View the web browser that Granted uses to open cloud consoles", 13 | Subcommands: []*cli.Command{&SetBrowserCommand, &SetSSOBrowserCommand}, 14 | Action: func(c *cli.Context) error { 15 | // return the default browser that is set 16 | conf, err := config.Load() 17 | if err != nil { 18 | return err 19 | } 20 | clio.Infof("Granted is using %s. To change this run `granted browser set`", conf.DefaultBrowser) 21 | 22 | return nil 23 | }, 24 | } 25 | 26 | var SetBrowserCommand = cli.Command{ 27 | Name: "set", 28 | Usage: "Change the web browser that Granted uses to open cloud consoles", 29 | Flags: []cli.Flag{&cli.StringFlag{Name: "browser", Aliases: []string{"b"}, Usage: "Specify a default browser without prompts, e.g `-b firefox`, `-b chrome`"}, 30 | &cli.StringFlag{Name: "path", Aliases: []string{"p"}, Usage: "Specify a path to the browser without prompts, requires -browser to be provided"}}, 31 | Action: func(c *cli.Context) (err error) { 32 | outcome := c.String("browser") 33 | path := c.String("path") 34 | 35 | if outcome == "" { 36 | if path != "" { 37 | clio.Info("-path flag must be used with -browser flag, provided path will be ignored") 38 | } 39 | outcome, err = browser.HandleManualBrowserSelection() 40 | if err != nil { 41 | return err 42 | } 43 | } 44 | 45 | return browser.ConfigureBrowserSelection(outcome, path) 46 | }, 47 | } 48 | 49 | var SetSSOBrowserCommand = cli.Command{ 50 | Name: "set-sso", 51 | Usage: "Change the web browser that Granted uses to sso flows", 52 | Flags: []cli.Flag{&cli.StringFlag{Name: "browser", Aliases: []string{"b"}, Usage: "Specify a default browser without prompts, e.g `-b firefox`, `-b chrome`"}, 53 | &cli.StringFlag{Name: "path", Aliases: []string{"p"}, Usage: "Specify a path to the browser without prompts, requires -browser to be provided"}}, 54 | Action: func(c *cli.Context) (err error) { 55 | outcome := c.String("browser") 56 | path := c.String("path") 57 | // save the detected browser as the default 58 | conf, err := config.Load() 59 | if err != nil { 60 | return err 61 | } 62 | var browserPath string 63 | 64 | if outcome == "" { 65 | if path != "" { 66 | clio.Info("-path flag must be used with -browser flag, provided path will be ignored") 67 | } 68 | customBrowserPath, err := browser.AskAndGetBrowserPath() 69 | if err != nil { 70 | return err 71 | } 72 | browserPath = customBrowserPath 73 | 74 | } 75 | 76 | conf.CustomSSOBrowserPath = browserPath 77 | err = conf.Save() 78 | if err != nil { 79 | return err 80 | } 81 | clio.Successf("Granted will default to using %s for SSO flows.", browserPath) 82 | return nil 83 | }, 84 | } 85 | -------------------------------------------------------------------------------- /scripts/assume.ps1: -------------------------------------------------------------------------------- 1 | #ASSUME - a powershell script to assume an AWS IAM role from the command-line 2 | 3 | #ASSUME_FLAG - what assumego told the shell to do 4 | #ASSUME_n - the data from assumego 5 | $env:SHELL="ps" 6 | $env:GRANTED_ALIAS_CONFIGURED="true" 7 | $ASSUME_FLAG, $ASSUME_1, $ASSUME_2, $ASSUME_3, $ASSUME_4, $ASSUME_5, $ASSUME_6, $ASSUME_7, $ASSUME_8, $ASSUME_9, $ASSUME_10, $ASSUME_11= ` 8 | $(& (Join-Path $PSScriptRoot -ChildPath "assumego") $args) -split '\s+' 9 | $env:ASSUME_STATUS = $LASTEXITCODE 10 | 11 | 12 | if ( $ASSUME_FLAG -eq "GrantedDesume" ) { 13 | $env:AWS_ACCESS_KEY_ID = "" 14 | $env:AWS_SECRET_ACCESS_KEY = "" 15 | $env:AWS_SESSION_TOKEN = "" 16 | $env:AWS_PROFILE = "" 17 | $env:AWS_REGION = "" 18 | $env:AWS_SESSION_EXPIRATION = "" 19 | $env:AWS_CREDENTIAL_EXPIRATION = "" 20 | $env:GRANTED_SSO = "" 21 | $env:GRANTED_SSO_START_URL = "" 22 | $env:GRANTED_SSO_ROLE_NAME = "" 23 | $env:GRANTED_SSO_REGION = "" 24 | $env:GRANTED_SSO_ACCOUNT_ID = "" 25 | exit 26 | } 27 | 28 | #ASSUME the profile 29 | elseif ( $ASSUME_FLAG -eq "GrantedAssume") { 30 | #Remove the environment variables associated with the AWS CLI, 31 | #ensuring all environment variables will be valid 32 | $env:AWS_ACCESS_KEY_ID = "" 33 | $env:AWS_SECRET_ACCESS_KEY = "" 34 | $env:AWS_SESSION_TOKEN = "" 35 | $env:AWS_PROFILE = "" 36 | $env:AWS_REGION = "" 37 | $env:AWS_SESSION_EXPIRATION = "" 38 | $env:AWS_CREDENTIAL_EXPIRATION = "" 39 | $env:GRANTED_SSO = "" 40 | $env:GRANTED_SSO_START_URL = "" 41 | $env:GRANTED_SSO_ROLE_NAME = "" 42 | $env:GRANTED_SSO_REGION = "" 43 | $env:GRANTED_SSO_ACCOUNT_ID = "" 44 | $env:ASSUME_COMMAND=$args 45 | if ( $ASSUME_1 -ne "None" ) { 46 | $env:AWS_ACCESS_KEY_ID = $ASSUME_1 47 | } 48 | if ( $ASSUME_2 -ne "None" ) { 49 | $env:AWS_SECRET_ACCESS_KEY = $ASSUME_2 50 | } 51 | 52 | if ( $ASSUME_3 -ne "None" ) { 53 | $env:AWS_SESSION_TOKEN = $ASSUME_3 54 | } 55 | 56 | if ( $ASSUME_4 -ne "None" ) { 57 | $env:AWS_PROFILE = $ASSUME_4 58 | } 59 | 60 | if ( $ASSUME_5 -ne "None" ) { 61 | $env:AWS_REGION = $ASSUME_5 62 | } 63 | 64 | if ( $ASSUME_6 -ne "None" ) { 65 | $env:AWS_SESSION_EXPIRATION = $ASSUME_6 66 | $env:AWS_CREDENTIAL_EXPIRATION = $ASSUME_6 67 | } 68 | 69 | if ( $ASSUME_7 -ne "None" ) { 70 | $env:GRANTED_SSO = $ASSUME_7 71 | } 72 | 73 | if ( $ASSUME_8 -ne "None" ) { 74 | $env:GRANTED_SSO_START_URL = $ASSUME_8 75 | } 76 | 77 | if ( $ASSUME_9 -ne "None" ) { 78 | $env:GRANTED_SSO_ROLE_NAME = $ASSUME_9 79 | } 80 | 81 | if ( $ASSUME_10 -ne "None" ) { 82 | $env:GRANTED_SSO_REGION = $ASSUME_10 83 | } 84 | 85 | if ( $ASSUME_11 -ne "None" ) { 86 | $env:GRANTED_SSO_ACCOUNT_ID = $ASSUME_11 87 | } 88 | } 89 | 90 | 91 | 92 | elseif ( $ASSUME_FLAG -eq "GrantedOutput") { 93 | Write-Host "$ASSUME_1" 94 | } 95 | 96 | exit $env:ASSUME_STATUS 97 | -------------------------------------------------------------------------------- /pkg/cfaws/assumer_aws_iam_mfa.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | "github.com/aws/aws-sdk-go-v2/config" 8 | "github.com/aws/aws-sdk-go-v2/service/sts" 9 | "github.com/common-fate/clio" 10 | "github.com/common-fate/granted/pkg/securestorage" 11 | "gopkg.in/ini.v1" 12 | ) 13 | 14 | type AwsIamMfaAssumer struct { 15 | AwsIamAssumer 16 | } 17 | 18 | func (aia *AwsIamMfaAssumer) AssumeTerminal(ctx context.Context, c *Profile, configOpts ConfigOpts) (aws.Credentials, error) { 19 | credentials, err := aia.AwsIamAssumer.AssumeTerminal(ctx, c, configOpts) 20 | 21 | // This case is already handled by AwsIamAssumer 22 | if !c.HasSecureStorageIAMCredentials { 23 | return credentials, err 24 | } 25 | 26 | if c.AWSConfig.MFASerial == "" { 27 | return credentials, nil 28 | } 29 | 30 | secureSessionCredentialStorage := securestorage.NewSecureSessionCredentialStorage() 31 | 32 | cachedCreds, ok, err := secureSessionCredentialStorage.GetCredentials(c.AWSConfig.Profile) 33 | if err != nil { 34 | return cachedCreds, err 35 | } 36 | 37 | if ok && !cachedCreds.Expired() { 38 | return cachedCreds, nil 39 | } 40 | 41 | stsClient := sts.New(sts.Options{ 42 | Credentials: aws.NewCredentialsCache(&CredProv{credentials}), 43 | Region: c.AWSConfig.Region, 44 | }) 45 | 46 | mfaCode, err := MfaTokenProvider() 47 | if err != nil { 48 | return credentials, err 49 | } 50 | 51 | sessionTokenOutput, err := stsClient.GetSessionToken(ctx, &sts.GetSessionTokenInput{ 52 | SerialNumber: aws.String(c.AWSConfig.MFASerial), 53 | TokenCode: aws.String(mfaCode), 54 | }) 55 | 56 | if err != nil { 57 | return credentials, err 58 | } 59 | 60 | newCredentials := aws.Credentials{ 61 | AccessKeyID: *sessionTokenOutput.Credentials.AccessKeyId, 62 | SecretAccessKey: *sessionTokenOutput.Credentials.SecretAccessKey, 63 | SessionToken: *sessionTokenOutput.Credentials.SessionToken, 64 | CanExpire: true, 65 | Expires: *sessionTokenOutput.Credentials.Expiration, 66 | Source: aia.Type(), 67 | } 68 | 69 | if err := secureSessionCredentialStorage.StoreCredentials(c.AWSConfig.Profile, newCredentials); err != nil { 70 | clio.Warnf("Error caching credentials, MFA token will be requested before current token is expired") 71 | } 72 | 73 | return newCredentials, nil 74 | 75 | } 76 | 77 | func (aia *AwsIamMfaAssumer) AssumeConsole(ctx context.Context, c *Profile, configOpts ConfigOpts) (aws.Credentials, error) { 78 | return aia.AwsIamAssumer.AssumeConsole(ctx, c, configOpts) 79 | } 80 | 81 | func (aia *AwsIamMfaAssumer) Type() string { 82 | return "AWS_IAM_MFA" 83 | } 84 | 85 | // Matches the profile type on whether it is not an sso profile. 86 | // this will also match other types that are not sso profiles so it should be the last option checked when determining the profile type 87 | func (aia *AwsIamMfaAssumer) ProfileMatchesType(rawProfile *ini.Section, parsedProfile config.SharedConfig) bool { 88 | return parsedProfile.SSOAccountID == "" && parsedProfile.MFASerial != "" 89 | } 90 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX?=/usr/local 2 | 3 | go-binary: 4 | go build -o ./bin/dgranted cmd/granted/main.go 5 | 6 | cli: go-binary 7 | mv ./bin/dgranted ${PREFIX}/bin/ 8 | ln -sf dgranted ${PREFIX}/bin/dassumego 9 | # replace references to "assumego" (the production symlink) with "dassumego" 10 | cat scripts/assume | sed 's/assumego/dassumego/g' > ${PREFIX}/bin/dassume && chmod +x ${PREFIX}/bin/dassume 11 | cat scripts/assume.fish | sed 's/assumego/dassumego/g' > ${PREFIX}/bin/dassume.fish && chmod +x ${PREFIX}/bin/dassume.fish 12 | 13 | clean: 14 | rm ${PREFIX}/bin/dassumego 15 | rm ${PREFIX}/bin/dassume 16 | rm ${PREFIX}/bin/dassume.fish 17 | 18 | aws-credentials: 19 | echo -e "\nAWS_ACCESS_KEY_ID=\"$$AWS_ACCESS_KEY_ID\"\nAWS_SECRET_ACCESS_KEY=\"$$AWS_SECRET_ACCESS_KEY\"\nAWS_SESSION_TOKEN=\"$$AWS_SESSION_TOKEN\"\nAWS_REGION=\"$$AWS_REGION\"" 20 | 21 | test-browser-binary: 22 | GOOS=linux go build -o ./bin/linux/tbrowser cmd/testing/browser/main.go 23 | GOOS=darwin GOARCH=amd64 go build -o ./bin/macos/tbrowser cmd/testing/browser/main.go 24 | GOOS=windows go build -o ./bin/windows/tbrowser.exe cmd/testing/browser/main.go 25 | 26 | test-creds-binary: 27 | GOOS=linux go build -o ./bin/linux/tcreds cmd/testing/creds/main.go 28 | GOOS=darwin GOARCH=amd64 go build -o ./bin/macos/tcreds cmd/testing/creds/main.go 29 | GOOS=windows go build -o ./bin/windows/tcreds.exe cmd/testing/creds/main.go 30 | 31 | test-binaries: test-browser-binary test-creds-binary 32 | 33 | ci-cli-all-platforms: test-binaries 34 | # build steps 35 | GOOS=linux go build -o ./bin/linux/dgranted cmd/granted/main.go 36 | GOOS=darwin GOARCH=amd64 go build -o ./bin/macos/dgranted cmd/granted/main.go 37 | GOOS=windows go build -o ./bin/windows/dgranted.exe cmd/granted/main.go 38 | # symlink steps 39 | ln -sf dgranted ./bin/linux/dassumego 40 | ln -sf dgranted ./bin/macos/dassumego 41 | ln -sf dgranted.exe ./bin/windows/dassumego.exe 42 | # replace references to "assumego" (the production symlink) with "dassumego" 43 | cat scripts/assume | sed 's/assumego/dassumego/g' > bin/linux/dassume && chmod +x bin/linux/dassume 44 | cat scripts/assume.fish | sed 's/assumego/dassumego/g' > bin/linux/dassume.fish && chmod +x bin/linux/dassume.fish 45 | cp bin/linux/dassume bin/macos/dassume 46 | cp bin/linux/dassume.fish bin/macos/dassume.fish 47 | cat scripts/assume.bat | sed 's/assumego/dassumego/g' > bin/windows/dassume.bat 48 | cat scripts/assume.ps1 | sed 's/assumego/dassumego/g' > bin/windows/dassume.ps1 49 | 50 | ## This will use the 'granted' binary and 'assume' symlink for dev build. 51 | ## Helpful to use dev build using 'granted' and 'assume' before release. 52 | cli-act-prod: go-binary assume-binary 53 | mv ./bin/dgranted ${PREFIX}/bin/granted 54 | ln -s granted ${PREFIX}/bin/dassumego 55 | # replace references to "assumego" (the production binary) with "dassumego" 56 | cat scripts/assume | sed 's/assumego/dassumego/g' > ${PREFIX}/bin/dassume && chmod +x ${PREFIX}/bin/dassume 57 | cat scripts/assume.fish | sed 's/assumego/dassumego/g' > ${PREFIX}/bin/dassume.fish && chmod +x ${PREFIX}/bin/dassume.fish 58 | mv ${PREFIX}/bin/dassume ${PREFIX}/bin/assume 59 | mv ${PREFIX}/bin/dassume.fish ${PREFIX}/bin/assume.fish -------------------------------------------------------------------------------- /pkg/granted/registry/setup.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "os" 5 | "path" 6 | 7 | "github.com/AlecAivazis/survey/v2" 8 | "github.com/common-fate/clio" 9 | "github.com/common-fate/clio/clierr" 10 | 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | var SetupCommand = cli.Command{ 15 | Name: "setup", 16 | Usage: "Setup a Profile Registry repository", 17 | Description: "Setup a granted registry repository", 18 | Subcommands: []*cli.Command{}, 19 | Flags: []cli.Flag{&cli.PathFlag{Name: "dir", Aliases: []string{"d"}, Usage: "Directory to setup the Profile Registry", Value: "granted-registry"}}, 20 | Action: func(c *cli.Context) error { 21 | dir := c.Path("dir") 22 | 23 | // check that it is an empty dir 24 | err := ensureConfigDoesntExist(c, dir) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | // mkdir granted-registry 30 | err = os.Mkdir(dir, 0755) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | // copy ~/.aws/config to ./config 36 | configFile, _, err := loadAWSConfigFile() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | var confirm bool 42 | s := &survey.Confirm{ 43 | Message: "Are you sure you want to copy all of the profiles from your AWS config file?", 44 | Default: true, 45 | } 46 | err = survey.AskOne(s, &confirm) 47 | if err != nil { 48 | return err 49 | } 50 | if !confirm { 51 | clio.Info("Cancelled registry setup") 52 | return nil 53 | } 54 | 55 | // now save cfg contents to ./config 56 | 57 | err = configFile.SaveTo(path.Join(dir, "config")) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | // create granted.yml 63 | 64 | f, err := os.Create(path.Join(dir, "granted.yml")) 65 | if err != nil { 66 | return err 67 | } 68 | defer f.Close() 69 | // now initialize the git repo 70 | err = gitInit(dir) 71 | if err != nil { 72 | return err 73 | } 74 | // write the default config to the granted.yml 75 | _, err = f.WriteString(`awsConfig: 76 | - ./config`) 77 | if err != nil { 78 | return err 79 | } 80 | clio.Infof("Successfully created valid profile registry 'granted-registry' in %s.", dir) 81 | clio.Info("Now push this repository to remote origin so that your team-members can sync to it.") 82 | return nil 83 | }, 84 | } 85 | 86 | // sanity check: verify that a config file doesn't already exist. 87 | // if it does, the user may have run this command by mistake. 88 | func ensureConfigDoesntExist(c *cli.Context, dir string) error { 89 | _, err := os.Open(path.Join(dir, "granted.yml")) 90 | if err != nil { 91 | return nil 92 | } 93 | 94 | // if we get here, the config file exists and is at risk of being overwritten. 95 | return clierr.New(("A granted.yml file already exists in this folder.\ngranted will exit to avoid overwriting this file, in case you've run this command by mistake."), 96 | clierr.Info(`Alternatively, take one of the following actions: 97 | a) run 'granted registry setup' in a different directory 98 | b) run 'granted registry add' to connect to an existing Profile Registry 99 | `)) 100 | } 101 | -------------------------------------------------------------------------------- /pkg/cfaws/region.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | const DefaultRegion = "us-east-1" 10 | 11 | // ExpandRegion takes a string and attemps to expand it into a fully formed region e.g ue1 -> us-east-1 12 | // 13 | // If region is an empty string, the DefaultRegion is returned 14 | // 15 | // ExpandRegion does not attempt to fully validate regions and may produce regions which do not exist, for example as2 -> ap-south-2 which is not a valid region 16 | func ExpandRegion(region string) (string, error) { 17 | // Region could come in one of three formats: 18 | // 1. No region specified 19 | if region == "" { 20 | return DefaultRegion, nil 21 | } 22 | // 2. A fully-qualified region. Assume that if there's one dash, it's valid. 23 | if strings.Contains(region, "-") { 24 | return region, nil 25 | } 26 | var major, minor, num string 27 | idx := 1 // Number of characters consumed from region 28 | // 3. Otherwise, we have a shortened region, like ue1 29 | if len(region) < 2 { 30 | return "", fmt.Errorf("region too short, needs at least two characters (eg ue)") 31 | } 32 | // Region might be one or two letters 33 | switch region[0] { 34 | case 'u': 35 | major = "us" 36 | if region[1] == 'g' { 37 | major = "us-gov" 38 | idx += 1 39 | } else if region[1] == 's' { 40 | // This will break if us-southeast-1 is ever created 41 | idx += 1 42 | } 43 | case 'e': 44 | major = "eu" 45 | if region[1] == 'u' { 46 | idx += 1 47 | } 48 | case 'a': 49 | major = "ap" 50 | if region[1] == 'f' { 51 | major = "af" 52 | idx += 1 53 | } else if region[1] == 'p' { 54 | idx += 1 55 | } 56 | case 'c': 57 | major = "ca" 58 | if region[1] == 'n' { 59 | major = "cn" 60 | idx += 1 61 | } else if region[1] == 'a' { 62 | idx += 1 63 | } 64 | case 'm': 65 | major = "me" 66 | // This will break if me-east-1 is ever created 67 | if region[1] == 'e' { 68 | idx += 1 69 | } 70 | case 's': 71 | major = "sa" 72 | if region[1] == 'a' { 73 | idx += 1 74 | } 75 | default: 76 | return "", fmt.Errorf("unknown region major (hint: try using the first letter of the region)") 77 | } 78 | region = region[idx:] 79 | idx = 1 80 | // Location might be one or two letters (n, nw) 81 | switch region[0] { 82 | case 'n', 's': 83 | if region[0] == 'n' { 84 | minor = "north" 85 | } else { 86 | minor = "south" 87 | } 88 | if len(region) > 1 { 89 | if region[1] == 'w' { 90 | minor += "west" 91 | idx += 1 92 | 93 | } else if region[1] == 'e' { 94 | minor += "east" 95 | idx += 1 96 | } 97 | } 98 | case 'e': 99 | minor = "east" 100 | case 'w': 101 | minor = "west" 102 | case 'c': 103 | minor = "central" 104 | default: 105 | return "", fmt.Errorf("unknown region minor in %s (found major: %s)", region, major) 106 | 107 | } 108 | region = region[idx:] 109 | if len(region) > 0 { 110 | _, err := strconv.Atoi(region) 111 | if err != nil { 112 | return "", fmt.Errorf("unknown region number in %s (found major: %s, minor: %s)", region, major, minor) 113 | } 114 | num = region 115 | } else { 116 | num = "1" 117 | } 118 | 119 | return fmt.Sprintf("%s-%s-%s", major, minor, num), nil 120 | } 121 | -------------------------------------------------------------------------------- /pkg/cfaws/granted_config_test.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "gopkg.in/ini.v1" 8 | ) 9 | 10 | func TestValidateCredentialProcess(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | arg string 14 | profileName string 15 | wantErr string 16 | }{ 17 | { 18 | name: "valid argument with correct profile name", 19 | arg: " granted credential-process --profile develop", 20 | profileName: "develop", 21 | }, 22 | { 23 | name: "valid argument with incorrect profile name", 24 | arg: "granted credential-process --profile abc", 25 | profileName: "develop", 26 | wantErr: "unmatched profile names. The profile name 'abc' provided to 'granted credential-process' does not match AWS profile name 'develop'", 27 | }, 28 | { 29 | name: "invalid argument", 30 | arg: "aws-sso-util --profile abc", 31 | profileName: "apple", 32 | wantErr: "unable to parse 'credential_process'. Looks like your credential_process isn't configured correctly. \n You need to add 'granted credential-process --profile '", 33 | }, 34 | } 35 | 36 | for _, tt := range tests { 37 | t.Run(tt.name, func(t *testing.T) { 38 | 39 | err := validateCredentialProcess(tt.arg, tt.profileName) 40 | if err != nil { 41 | if err.Error() != tt.wantErr { 42 | t.Fatal(err) 43 | } 44 | } 45 | }) 46 | } 47 | } 48 | 49 | // tests support for aws sso-session configuration 50 | func TestSSOSessionSupport(t *testing.T) { 51 | tests := []struct { 52 | name string 53 | file string 54 | profileName string 55 | wantSSOSession *SSOSession 56 | }{ 57 | { 58 | name: "valid argument with correct profile name", 59 | file: `[profile testing] 60 | sso_session = testing-sso 61 | sso_account_id = 12345678912 62 | sso_role_name = Test 63 | region = ap-southeast-2 64 | 65 | [sso-session testing-sso] 66 | sso_start_url = https://d-12345678910.awsapps.com/start 67 | sso_region = ap-southeast-2 68 | `, 69 | profileName: "testing", 70 | wantSSOSession: &SSOSession{ 71 | SSORegion: "ap-southeast-2", 72 | SSOStartURL: "https://d-12345678910.awsapps.com/start", 73 | }, 74 | }, 75 | } 76 | 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | l := loader{fileString: tt.file} 80 | profiles, err := LoadProfiles(l, nooploader{}) 81 | if err != nil { 82 | t.Fatal(err) 83 | } 84 | profile, err := profiles.Profile(tt.profileName) 85 | if err != nil { 86 | t.Fatal(err) 87 | } 88 | assert.Equal(t, tt.wantSSOSession, profile.SSOSession) 89 | }) 90 | } 91 | } 92 | 93 | type loader struct { 94 | fileString string 95 | } 96 | 97 | func (l loader) Path() string { return "" } 98 | func (l loader) Load() (*ini.File, error) { 99 | testConfigFile, err := ini.LoadSources(ini.LoadOptions{}, []byte(l.fileString)) 100 | if err != nil { 101 | return nil, err 102 | } 103 | return testConfigFile, nil 104 | } 105 | 106 | type nooploader struct { 107 | } 108 | 109 | func (l nooploader) Path() string { return "" } 110 | func (l nooploader) Load() (*ini.File, error) { 111 | return ini.Empty(), nil 112 | } 113 | -------------------------------------------------------------------------------- /pkg/console/aws.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | "github.com/common-fate/clio" 11 | ) 12 | 13 | type AWS struct { 14 | Profile string 15 | Region string 16 | Service string 17 | Destination string 18 | } 19 | 20 | // awsSession is the JSON payload sent to AWS 21 | // to exchange an AWS session for a console URL. 22 | type awsSession struct { 23 | // SessionID maps to AWS Access Key ID 24 | SessionID string `json:"sessionId"` 25 | // SessionKey maps to AWS Secret Access Key 26 | SessionKey string `json:"sessionKey"` 27 | // SessionToken maps to AWS Session Token 28 | SessionToken string `json:"sessionToken"` 29 | } 30 | 31 | // URL retrieves an authorised access URL for the AWS console. The URL includes a security token which is retrieved 32 | // by exchanging AWS session credentials using the AWS federation endpoint. 33 | // 34 | // see: https://docs.aws.amazon.com/IAM/latest/UserGuide/example_sts_Scenario_ConstructFederatedUrl_section.html 35 | func (a AWS) URL(creds aws.Credentials) (string, error) { 36 | sess := awsSession{ 37 | SessionID: creds.AccessKeyID, 38 | SessionKey: creds.SecretAccessKey, 39 | SessionToken: creds.SessionToken, 40 | } 41 | sessJSON, err := json.Marshal(sess) 42 | if err != nil { 43 | return "", err 44 | } 45 | 46 | partition := GetPartitionFromRegion(a.Region) 47 | clio.Debugf("Partition is detected as %s for region %s...\n", partition, a.Region) 48 | 49 | u := url.URL{ 50 | Scheme: "https", 51 | Host: partition.RegionalHostString(a.Region), 52 | Path: "/federation", 53 | } 54 | q := u.Query() 55 | q.Add("Action", "getSigninToken") 56 | q.Add("Session", string(sessJSON)) 57 | u.RawQuery = q.Encode() 58 | 59 | res, err := http.Get(u.String()) 60 | if err != nil { 61 | return "", err 62 | } 63 | if res.StatusCode != http.StatusOK { 64 | return "", fmt.Errorf("opening console failed with code %v", res.StatusCode) 65 | } 66 | 67 | token := struct { 68 | SigninToken string `json:"SigninToken"` 69 | }{} 70 | 71 | err = json.NewDecoder(res.Body).Decode(&token) 72 | if err != nil { 73 | return "", err 74 | } 75 | 76 | u = url.URL{ 77 | Scheme: "https", 78 | Host: partition.RegionalHostString(a.Region), 79 | Path: "/federation", 80 | } 81 | 82 | dest, err := makeDestinationURL(a.Service, a.Region, a.Destination) 83 | 84 | if err != nil { 85 | return "", err 86 | } 87 | q = u.Query() 88 | q.Add("Action", "login") 89 | q.Add("Issuer", "") 90 | q.Add("SigninToken", token.SigninToken) 91 | q.Add("Destination", dest) 92 | u.RawQuery = q.Encode() 93 | return u.String(), nil 94 | } 95 | 96 | func makeDestinationURL(service string, region string, destination string) (string, error) { 97 | // if destination is provided, use it 98 | if destination != "" { 99 | return destination, nil 100 | } 101 | partition := GetPartitionFromRegion(region) 102 | prefix := partition.RegionalConsoleHostString(region) 103 | if ServiceMap[service] == "" { 104 | clio.Warnf("We don't recognize service %s but we'll try and open it anyway (you may receive a 404 page)\n", service) 105 | } else { 106 | service = ServiceMap[service] 107 | } 108 | dest := prefix + service + "/home" 109 | 110 | // excluding region here if the service is a part of the global service list 111 | // incomplete list of global services 112 | _, global := globalServiceMap[service] 113 | hasRegion := region != "" 114 | if !global && hasRegion { 115 | dest = dest + "?region=" + region 116 | 117 | } 118 | return dest, nil 119 | } 120 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to granted 2 | 3 | We welcome all contributions to Granted. Please read our [Contributor Code of Conduct](./CODE_OF_CONDUCT.md). 4 | 5 | ## Requirements 6 | 7 | The development instructions below pertain to Unix-based systems like Linux and MacOS. If you're running Windows and would like to contribute to Granted, feel free to [reach out to us on Slack](https://join.slack.com/t/commonfatecommunity/shared_invite/zt-q4m96ypu-_gYlRWD3k5rIsaSsqP7QMg) if you're having issues setting up your development environment. 8 | 9 | In order to develop Granted you'll need the following: 10 | 11 | - [Go 1.19](https://go.dev/doc/install) 12 | 13 | ## Getting started 14 | 15 | Granted consists of two binaries: 16 | 17 | - `granted`: used to manage Granted configuration 18 | 19 | - `assume`: used to assume roles 20 | 21 | You can read about how the `assume` binary exports environment variables [here](https://docs.commonfate.io/granted/internals/shell-alias). 22 | 23 | In development we use `dassume` and `dgranted` to avoid collisions between the released and development binaries. 24 | 25 | To build the Granted CLI you can run 26 | 27 | ``` 28 | make cli 29 | ``` 30 | 31 | The CLI should now be available on your PATH as `dgranted` and `dassume`. 32 | 33 | ## Creating a bug report 34 | 35 | Receiving bug reports is great, it helps us identify and patch issues in the application so that the users have the best possible experience. But it's important to include the necessary information in the bug report so that the maintainers are able to get to the bottom of the problem with as much context as possible. 36 | 37 | When opening a bug report we ask that you please include the following information: 38 | 39 | - Your Granted version `granted -v` 40 | - If applicable and relevant your .aws config file, found at `~/.aws/config` **(excluding any account IDs or SSO start URLs)** 41 | - Details surrounding the bug and steps to replicate 42 | - If possible an example of the bug 43 | 44 | Some things to try before opening a new issue: 45 | 46 | - Make sure you're running the latest version of Granted 47 | - Check if there is already an open issue surrounding your bug and add to that open issue 48 | 49 | **Example:** 50 | 51 | > Short and descriptive example bug report title 52 | > 53 | > Description of the bug 54 | > 55 | > Granted Version: v0.1.5 56 | > 57 | > Your config 58 | > 59 | > ``` 60 | > [profile PROFNAME] 61 | > sso_start_url=*** 62 | > sso_region=ap-southeast-2 63 | > sso_account_id=*** 64 | > sso_role_name=ROLE_NAME 65 | > region=ap-southeast-2 66 | > credential_process=aws-sso-credential-process --profile PROFNAME 67 | > ``` 68 | > 69 | > Any other information you want to share that is relevant to the issue being 70 | > reported. This might include the lines of code that you have identified as 71 | > causing the bug, and potential solutions (and your opinions on their 72 | > merits). 73 | 74 | # Technical Notes 75 | 76 | Before you get started developing on Granted, these notes will help to explain some key concepts in the codebase. 77 | 78 | ## IO 79 | 80 | Granted consists of 2 binaries, `granted` and `assumego`. 81 | When you run `assume` a shell script will run which wraps the assumego binary. 82 | This is required so that assume can set environment variables in your terminal. 83 | 84 | For this reason, informational output should be made to os.StdErr. We use a logging library called clio 85 | The library is configured in the entry point of granted and assume with the correct output writers. 86 | 87 | So when logging simply use the relevant log type 88 | 89 | ```go 90 | clio.Info("hello") 91 | clio.Warn("hello") 92 | clio.Success("hello") 93 | clio.Error("hello") 94 | clio.Error("hello") 95 | ``` 96 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /pkg/cfaws/assumer_aws_credential_process.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | "github.com/aws/aws-sdk-go-v2/config" 8 | "github.com/aws/aws-sdk-go-v2/credentials/processcreds" 9 | "github.com/aws/aws-sdk-go-v2/credentials/stscreds" 10 | "github.com/aws/aws-sdk-go-v2/service/sts" 11 | "gopkg.in/ini.v1" 12 | ) 13 | 14 | // Implements Assumer using the aws credential_process standard 15 | type CredentialProcessAssumer struct { 16 | } 17 | 18 | func loadCredProcessCreds(ctx context.Context, c *Profile) (aws.Credentials, error) { 19 | var credProcessCommand string 20 | for _, item := range c.RawConfig.Keys() { 21 | if item.Name() == "credential_process" { 22 | credProcessCommand = item.Value() 23 | break 24 | } 25 | } 26 | p := processcreds.NewProvider(credProcessCommand) 27 | return p.Retrieve(ctx) 28 | } 29 | 30 | func (cpa *CredentialProcessAssumer) AssumeTerminal(ctx context.Context, c *Profile, configOpts ConfigOpts) (aws.Credentials, error) { 31 | // if the profile has parents, then we need to first use credential process to assume the root profile. 32 | // then assume each of the chained profiles 33 | if len(c.Parents) != 0 { 34 | p := c.Parents[0] 35 | creds, err := loadCredProcessCreds(ctx, p) 36 | if err != nil { 37 | return creds, err 38 | } 39 | for _, p := range c.Parents[1:] { 40 | region, err := p.Region(ctx) 41 | if err != nil { 42 | return aws.Credentials{}, err 43 | } 44 | stsp := stscreds.NewAssumeRoleProvider(sts.New(sts.Options{Credentials: aws.NewCredentialsCache(&CredProv{creds}), Region: region}), p.AWSConfig.RoleARN, func(aro *stscreds.AssumeRoleOptions) { 45 | if p.AWSConfig.RoleSessionName != "" { 46 | aro.RoleSessionName = p.AWSConfig.RoleSessionName 47 | } else { 48 | aro.RoleSessionName = sessionName() 49 | } 50 | if p.AWSConfig.MFASerial != "" { 51 | aro.SerialNumber = &p.AWSConfig.MFASerial 52 | aro.TokenProvider = MfaTokenProvider 53 | } else if c.AWSConfig.MFASerial != "" { 54 | aro.SerialNumber = &c.AWSConfig.MFASerial 55 | aro.TokenProvider = MfaTokenProvider 56 | } 57 | aro.Duration = configOpts.Duration 58 | }) 59 | creds, err = stsp.Retrieve(ctx) 60 | if err != nil { 61 | return creds, err 62 | } 63 | } 64 | region, err := c.Region(ctx) 65 | if err != nil { 66 | return aws.Credentials{}, err 67 | } 68 | stsp := stscreds.NewAssumeRoleProvider(sts.New(sts.Options{Credentials: aws.NewCredentialsCache(&CredProv{creds}), Region: region}), c.AWSConfig.RoleARN, func(aro *stscreds.AssumeRoleOptions) { 69 | if c.AWSConfig.RoleSessionName != "" { 70 | aro.RoleSessionName = c.AWSConfig.RoleSessionName 71 | } else { 72 | aro.RoleSessionName = sessionName() 73 | } 74 | if c.AWSConfig.MFASerial != "" { 75 | aro.SerialNumber = &c.AWSConfig.MFASerial 76 | aro.TokenProvider = MfaTokenProvider 77 | } 78 | aro.Duration = configOpts.Duration 79 | }) 80 | return stsp.Retrieve(ctx) 81 | } 82 | 83 | return loadCredProcessCreds(ctx, c) 84 | 85 | } 86 | 87 | func (cpa *CredentialProcessAssumer) AssumeConsole(ctx context.Context, c *Profile, configOpts ConfigOpts) (aws.Credentials, error) { 88 | return cpa.AssumeTerminal(ctx, c, configOpts) 89 | } 90 | 91 | // A unique key which identifies this assumer e.g AWS-SSO or GOOGLE-AWS-AUTH 92 | func (cpa *CredentialProcessAssumer) Type() string { 93 | return "AWS_CREDENTIAL_PROCESS" 94 | } 95 | 96 | // inspect for any credential processes with the saml2aws tool 97 | func (cpa *CredentialProcessAssumer) ProfileMatchesType(rawProfile *ini.Section, parsedProfile config.SharedConfig) bool { 98 | for _, k := range rawProfile.KeyStrings() { 99 | if k == "credential_process" { 100 | return true 101 | } 102 | } 103 | return false 104 | } 105 | -------------------------------------------------------------------------------- /pkg/cfaws/access_request.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/common-fate/clio" 7 | "github.com/common-fate/clio/clierr" 8 | "github.com/common-fate/granted/pkg/accessrequest" 9 | grantedConfig "github.com/common-fate/granted/pkg/config" 10 | "gopkg.in/ini.v1" 11 | ) 12 | 13 | // GetGrantedApprovalsURL returns the URL which users can request access to a particular role at. 14 | // 15 | // To return a request URL, a base URL for a Granted Approvals deployment must be set. The base URL can be provided in 16 | // a couple of ways and is read in the following order of priority: 17 | // 18 | // 1. By setting the '--url' flag with the Granted credentials_process command 19 | // 20 | // 2. By setting a global request URL with the command 'granted settings request-url set' 21 | // 22 | // If neither of the approaches above returns a URL, this method returns a message indicating that the request URL 23 | // hasn't been set up. 24 | func FormatAWSErrorWithGrantedApprovalsURL(awsError error, rawConfig *ini.Section, gConf grantedConfig.Config, SSORoleName string, SSOAccountId string) error { 25 | cliError := &clierr.Err{ 26 | Err: awsError.Error(), 27 | } 28 | // try and extract a --url flag from the AWS profile, like the following: 29 | // [profile my-profile] 30 | // credential_process = granted credential-process --url https://example.com 31 | // This flag takes the highest precendence if it is set. 32 | url := parseURLFlagFromConfig(rawConfig) 33 | if url == "" { 34 | // if the --url flag wasn't found, try and load the global request URL setting. 35 | url = gConf.AccessRequestURL 36 | } 37 | 38 | if url != "" { 39 | latestRole := accessrequest.Role{ 40 | Account: SSOAccountId, 41 | Role: SSORoleName, 42 | } 43 | err := latestRole.Save() 44 | if err != nil { 45 | clio.Errorw("error saving latest role", "error", err) 46 | } 47 | 48 | // if we have a request URL, we can prompt the user to make a request by visiting the URL. 49 | requestURL := latestRole.URL(url) 50 | // need to escape the % symbol in the request url which has been query escaped so that fmt doesn't try to substitute it 51 | cliError.Messages = append(cliError.Messages, clierr.Warn("You need to request access to this role:"), clierr.Warn(requestURL), clierr.Warn("or run: 'granted exp request latest'")) 52 | return cliError 53 | } 54 | 55 | // otherwise, there is no request URL configured. Let the user know that they can set one up if they are using Granted Approvals 56 | // remember that not all users of credential process will be using approvals 57 | cliError.Messages = append(cliError.Messages, 58 | clierr.Info("It looks like you don't have the right permissions to access this role"), 59 | clierr.Info("If you are using Common Fate to manage this role you can configure the Granted CLI with a request URL so that you can be directed to your Granted Approvals instance to make a new access request the next time you have this error"), 60 | clierr.Info("To configure a URL to request access to this role with 'granted settings request-url set 1 { 86 | return matchedValues[1] 87 | } 88 | return "" 89 | } 90 | -------------------------------------------------------------------------------- /pkg/granted/settings/set.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strconv" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | "github.com/common-fate/clio" 10 | "github.com/common-fate/granted/pkg/config" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | var SetConfigCommand = cli.Command{ 15 | Name: "set", 16 | Usage: "Set a value in settings", 17 | Flags: []cli.Flag{ 18 | &cli.StringFlag{Name: "setting", Aliases: []string{"s"}, Usage: "The name of a configuration setting, currently only string, int and bool types are supported. e.g 'DisableUsageTips'. For other configuration, set the value using builtin commands or by directly modifying the config file for advanced use cases."}, 19 | &cli.StringFlag{Name: "value", Aliases: []string{"v"}, Usage: "The value to set the configuration setting to"}, 20 | }, 21 | Action: func(c *cli.Context) error { 22 | cfg, err := config.Load() 23 | if err != nil { 24 | return err 25 | } 26 | 27 | // Get the type and value of the Config struct 28 | configType := reflect.TypeOf(*cfg) 29 | configValue := reflect.ValueOf(cfg).Elem() 30 | type field struct { 31 | ftype reflect.StructField 32 | fvalue reflect.Value 33 | } 34 | var fields []string 35 | var fieldMap = make(map[string]field) 36 | // Iterate over the fields of the Config struct 37 | for i := 0; i < configType.NumField(); i++ { 38 | fieldType := configType.Field(i) 39 | kind := fieldType.Type.Kind() 40 | if kind == reflect.Bool || kind == reflect.String || kind == reflect.Int { 41 | fieldValue := configValue.Field(i) 42 | fields = append(fields, fieldType.Name) 43 | fieldMap[fieldType.Name] = field{ 44 | fvalue: fieldValue, 45 | ftype: fieldType, 46 | } 47 | } 48 | } 49 | 50 | var selectedFieldName = c.String("setting") 51 | if selectedFieldName == "" { 52 | p := &survey.Select{ 53 | Message: "Select the configuration to change", 54 | Options: fields, 55 | } 56 | err = survey.AskOne(p, &selectedFieldName) 57 | if err != nil { 58 | return err 59 | } 60 | } 61 | 62 | var selectedField field 63 | var ok bool 64 | selectedField, ok = fieldMap[selectedFieldName] 65 | if !ok { 66 | return fmt.Errorf("the selected field %s is not a valid config parameter", selectedFieldName) 67 | } 68 | // Prompt the user to update the field 69 | var value interface{} 70 | var prompt survey.Prompt 71 | switch selectedField.ftype.Type.Kind() { 72 | case reflect.Bool: 73 | if !c.IsSet("value") { 74 | prompt = &survey.Confirm{ 75 | Message: fmt.Sprintf("Enter new value for %s:", selectedFieldName), 76 | Default: selectedField.fvalue.Bool(), 77 | } 78 | err = survey.AskOne(prompt, &value) 79 | if err != nil { 80 | return err 81 | } 82 | } else { 83 | valueStr := c.String("value") 84 | value, err = strconv.ParseBool(valueStr) 85 | if err != nil { 86 | return err 87 | } 88 | } 89 | 90 | case reflect.String: 91 | if !c.IsSet("value") { 92 | var str string 93 | prompt = &survey.Input{ 94 | Message: fmt.Sprintf("Enter new value for %s:", selectedFieldName), 95 | Default: fmt.Sprintf("%v", selectedField.fvalue.Interface()), 96 | } 97 | err = survey.AskOne(prompt, &str) 98 | if err != nil { 99 | return err 100 | } 101 | value = str 102 | } else { 103 | value = c.String("value") 104 | } 105 | case reflect.Int: 106 | if !c.IsSet("value") { 107 | prompt = &survey.Input{ 108 | Message: fmt.Sprintf("Enter new value for %s:", selectedFieldName), 109 | Default: fmt.Sprintf("%v", selectedField.fvalue.Interface()), 110 | } 111 | err = survey.AskOne(prompt, &value) 112 | if err != nil { 113 | return err 114 | } 115 | } else { 116 | valueInt := c.String("value") 117 | value, err = strconv.Atoi(valueInt) 118 | if err != nil { 119 | return err 120 | } 121 | } 122 | } 123 | 124 | // Set the new value for the field 125 | newValue := reflect.ValueOf(value) 126 | if newValue.Type().ConvertibleTo(selectedField.ftype.Type) { 127 | selectedField.fvalue.Set(newValue.Convert(selectedField.ftype.Type)) 128 | } else { 129 | return fmt.Errorf("invalid type for %s", selectedField.ftype.Name) 130 | } 131 | 132 | clio.Infof("Updating the value of %s to %v", selectedFieldName, value) 133 | err = cfg.Save() 134 | if err != nil { 135 | return err 136 | } 137 | clio.Success("Config updated successfully") 138 | return nil 139 | }, 140 | } 141 | -------------------------------------------------------------------------------- /pkg/granted/credential_process.go: -------------------------------------------------------------------------------- 1 | package granted 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go-v2/aws" 9 | "github.com/pkg/errors" 10 | 11 | "github.com/common-fate/clio" 12 | "github.com/common-fate/granted/pkg/cfaws" 13 | "github.com/common-fate/granted/pkg/config" 14 | "github.com/common-fate/granted/pkg/securestorage" 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | // AWS Creds consumed by credential_process must adhere to this schema 19 | // https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html 20 | type awsCredsStdOut struct { 21 | Version int `json:"Version"` 22 | AccessKeyID string `json:"AccessKeyId"` 23 | SecretAccessKey string `json:"SecretAccessKey"` 24 | SessionToken string `json:"SessionToken,omitempty"` 25 | Expiration string `json:"Expiration,omitempty"` 26 | } 27 | 28 | var CredentialProcess = cli.Command{ 29 | Name: "credential-process", 30 | Usage: "Exports AWS session credentials for use with AWS CLI credential_process", 31 | Flags: []cli.Flag{ 32 | &cli.StringFlag{Name: "profile", Required: true}, 33 | &cli.StringFlag{Name: "url"}, 34 | &cli.DurationFlag{Name: "window", Value: 15 * time.Minute}, 35 | &cli.BoolFlag{Name: "auto-login", Usage: "automatically open the configured browser to log in if needed"}, 36 | }, 37 | Action: func(c *cli.Context) error { 38 | cfg, err := config.Load() 39 | if err != nil { 40 | return err 41 | } 42 | var needsRefresh bool 43 | var credentials aws.Credentials 44 | profileName := c.String("profile") 45 | autoLogin := c.Bool("auto-login") 46 | secureSessionCredentialStorage := securestorage.NewSecureSessionCredentialStorage() 47 | clio.Debugw("running credential process with config", "profile", profileName, "url", c.String("url"), "window", c.Duration("window"), "disableCredentialProcessCache", cfg.DisableCredentialProcessCache) 48 | if !cfg.DisableCredentialProcessCache { 49 | creds, ok, err := secureSessionCredentialStorage.GetCredentials(profileName) 50 | if err != nil { 51 | return err 52 | } 53 | if !ok { 54 | clio.Debugw("refreshing credentials", "reason", "not found") 55 | needsRefresh = true 56 | } else { 57 | clio.Debugw("credentials found in cache", "expires", creds.Expires.String(), "canExpire", creds.CanExpire, "timeNow", time.Now().String(), "refreshIfBeforeNow", creds.Expires.Add(-c.Duration("window")).String()) 58 | if creds.CanExpire && creds.Expires.Add(-c.Duration("window")).Before(time.Now()) { 59 | clio.Debugw("refreshing credentials", "reason", "credentials are expired") 60 | needsRefresh = true 61 | } else { 62 | clio.Debugw("using cached credentials") 63 | credentials = creds 64 | } 65 | } 66 | } else { 67 | clio.Debugw("refreshing credentials", "reason", "credential process cache is disabled via config") 68 | needsRefresh = true 69 | } 70 | 71 | if needsRefresh { 72 | profiles, err := cfaws.LoadProfilesFromDefaultFiles() 73 | if err != nil { 74 | return err 75 | } 76 | 77 | profile, err := profiles.LoadInitialisedProfile(c.Context, profileName) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | duration := time.Hour 83 | if profile.AWSConfig.RoleDurationSeconds != nil { 84 | duration = *profile.AWSConfig.RoleDurationSeconds 85 | } 86 | 87 | credentials, err = profile.AssumeTerminal(c.Context, cfaws.ConfigOpts{Duration: duration, UsingCredentialProcess: true, CredentialProcessAutoLogin: autoLogin}) 88 | if err != nil { 89 | return err 90 | } 91 | if !cfg.DisableCredentialProcessCache { 92 | clio.Debugw("storing refreshed credentials in credential process cache", "expires", credentials.Expires.String(), "canExpire", credentials.CanExpire, "timeNow", time.Now().String()) 93 | if err := secureSessionCredentialStorage.StoreCredentials(profileName, credentials); err != nil { 94 | return err 95 | } 96 | } 97 | } 98 | 99 | out := awsCredsStdOut{ 100 | Version: 1, 101 | AccessKeyID: credentials.AccessKeyID, 102 | SecretAccessKey: credentials.SecretAccessKey, 103 | SessionToken: credentials.SessionToken, 104 | } 105 | if credentials.CanExpire { 106 | out.Expiration = credentials.Expires.Format(time.RFC3339) 107 | } 108 | 109 | jsonOut, err := json.Marshal(out) 110 | if err != nil { 111 | return errors.Wrap(err, "marshalling session credentials") 112 | } 113 | 114 | fmt.Println(string(jsonOut)) 115 | return nil 116 | }, 117 | } 118 | -------------------------------------------------------------------------------- /pkg/securestorage/securestorage.go: -------------------------------------------------------------------------------- 1 | package securestorage 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path" 7 | 8 | "github.com/99designs/keyring" 9 | "github.com/AlecAivazis/survey/v2" 10 | 11 | "github.com/common-fate/clio" 12 | "github.com/common-fate/granted/pkg/config" 13 | "github.com/common-fate/granted/pkg/testable" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | type SecureStorage struct { 18 | StorageSuffix string 19 | } 20 | 21 | // returns false if the key is not found, true if it is found, or false and an error if there was a keyring related error 22 | func (s *SecureStorage) HasKey(key string) (bool, error) { 23 | ring, err := s.openKeyring() 24 | if err != nil { 25 | return false, err 26 | } 27 | _, err = ring.Get(key) 28 | if err == keyring.ErrKeyNotFound { 29 | return false, nil 30 | } 31 | if err != nil { 32 | return false, err 33 | } 34 | return true, nil 35 | } 36 | 37 | // returns keyring.ErrKeyNotFound if not found 38 | func (s *SecureStorage) Retrieve(key string, target interface{}) error { 39 | ring, err := s.openKeyring() 40 | if err != nil { 41 | return err 42 | } 43 | keyringItem, err := ring.Get(key) 44 | if err != nil { 45 | return err 46 | } 47 | return json.Unmarshal(keyringItem.Data, &target) 48 | } 49 | 50 | func (s *SecureStorage) Store(key string, payload interface{}) error { 51 | ring, err := s.openKeyring() 52 | if err != nil { 53 | return err 54 | } 55 | b, err := json.Marshal(payload) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | return ring.Set(keyring.Item{ 61 | Key: key, // store with the corresponding key 62 | Data: b, // store the bytes 63 | }) 64 | } 65 | 66 | func (s *SecureStorage) Clear(key string) error { 67 | ring, err := s.openKeyring() 68 | if err != nil { 69 | return err 70 | } 71 | return ring.Remove(key) 72 | } 73 | 74 | func (s *SecureStorage) List() ([]keyring.Item, error) { 75 | tokenList := []keyring.Item{} 76 | ring, err := s.openKeyring() 77 | if err != nil { 78 | return nil, err 79 | } 80 | keys, err := ring.Keys() 81 | if err != nil { 82 | return nil, err 83 | } 84 | for _, k := range keys { 85 | item, err := ring.Get(k) 86 | if err != nil { 87 | return nil, err 88 | } 89 | tokenList = append(tokenList, item) 90 | 91 | } 92 | return tokenList, nil 93 | } 94 | 95 | func (s *SecureStorage) ListKeys() ([]string, error) { 96 | ring, err := s.openKeyring() 97 | if err != nil { 98 | return nil, err 99 | } 100 | return ring.Keys() 101 | } 102 | 103 | func (s *SecureStorage) openKeyring() (keyring.Keyring, error) { 104 | cfg, err := config.Load() 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | grantedFolder, err := config.GrantedConfigFolder() 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | secureStoragePath := path.Join(grantedFolder, "secure-storage-"+s.StorageSuffix) 115 | name := "granted-" + s.StorageSuffix 116 | c := keyring.Config{ 117 | ServiceName: name, 118 | 119 | // MacOS keychain 120 | KeychainName: "login", 121 | KeychainTrustApplication: true, 122 | 123 | // KDE Wallet 124 | KWalletAppID: name, 125 | KWalletFolder: name, 126 | 127 | // Windows 128 | WinCredPrefix: name, 129 | 130 | // freedesktop.org's Secret Service 131 | LibSecretCollectionName: name, 132 | 133 | // Pass (https://www.passwordstore.org/) 134 | PassPrefix: name, 135 | 136 | // Fallback encrypted file 137 | FileDir: secureStoragePath, 138 | FilePasswordFunc: func(s string) (string, error) { 139 | in := survey.Password{Message: s} 140 | var out string 141 | withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) 142 | err := testable.AskOne(&in, &out, withStdio) 143 | return out, err 144 | }, 145 | } 146 | 147 | // enable debug logging if the verbose flag is set in the CLI 148 | keyring.Debug = clio.IsDebug() 149 | 150 | if cfg.Keyring != nil { 151 | if cfg.Keyring.Backend != nil { 152 | c.AllowedBackends = []keyring.BackendType{keyring.BackendType(*cfg.Keyring.Backend)} 153 | } 154 | if cfg.Keyring.KeychainName != nil { 155 | c.KeychainName = *cfg.Keyring.KeychainName 156 | } 157 | if cfg.Keyring.FileDir != nil { 158 | c.FileDir = *cfg.Keyring.FileDir 159 | } 160 | if cfg.Keyring.LibSecretCollectionName != nil { 161 | c.LibSecretCollectionName = *cfg.Keyring.LibSecretCollectionName 162 | } 163 | } 164 | 165 | k, err := keyring.Open(c) 166 | if err != nil { 167 | return nil, errors.Wrap(err, "opening keyring") 168 | } 169 | 170 | return k, nil 171 | } 172 | 173 | // Keyring returns the underlying keyring associated with the storage. 174 | func (s *SecureStorage) Keyring() (keyring.Keyring, error) { 175 | return s.openKeyring() 176 | } 177 | -------------------------------------------------------------------------------- /pkg/granted/completion.go: -------------------------------------------------------------------------------- 1 | package granted 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "errors" 7 | "fmt" 8 | "html/template" 9 | "os" 10 | "os/user" 11 | "path" 12 | 13 | "github.com/common-fate/clio" 14 | "github.com/common-fate/granted/internal/build" 15 | "github.com/common-fate/granted/pkg/assume" 16 | "github.com/common-fate/granted/pkg/config" 17 | "github.com/common-fate/granted/pkg/shells" 18 | "github.com/urfave/cli/v2" 19 | ) 20 | 21 | //go:embed templates 22 | var templateFiles embed.FS 23 | var flags = []cli.Flag{ 24 | &cli.StringFlag{ 25 | Name: "shell", 26 | Aliases: []string{"s"}, 27 | Usage: "Shell to install completions for (fish, zsh, bash)", 28 | Required: true, 29 | }, 30 | } 31 | 32 | var CompletionCommand = cli.Command{ 33 | Name: "completion", 34 | Usage: "Add autocomplete to your granted cli installation", 35 | Flags: flags, 36 | Action: func(c *cli.Context) (err error) { 37 | shell := c.String("shell") 38 | switch shell { 39 | case "fish": 40 | err = installFishCompletions(c) 41 | case "zsh": 42 | err = installZSHCompletions(c) 43 | case "bash": 44 | err = installBashCompletions(c) 45 | default: 46 | clio.Info("To install completions for other shells, please see our docs: https://docs.commonfate.io/granted/configuration#autocompletion") 47 | } 48 | return err 49 | }, 50 | 51 | Description: "Install completions for fish, zsh, or bash. To install completions for other shells, please see our docs:\nhttps://docs.commonfate.io/granted/configuration#autocompletion\n", 52 | } 53 | 54 | func installFishCompletions(c *cli.Context) error { 55 | assumeApp := assume.GetCliApp() 56 | c.App.Name = build.GrantedBinaryName() 57 | assumeApp.Name = build.AssumeScriptName() 58 | grantedAppOutput, _ := c.App.ToFishCompletion() 59 | assumeAppOutput, _ := assumeApp.ToFishCompletion() 60 | combinedOutput := fmt.Sprintf("%s\n%s", grantedAppOutput, assumeAppOutput) 61 | 62 | // try to fetch user home dir 63 | user, _ := user.Current() 64 | 65 | executableDir := user.HomeDir + "/.config/fish/completions/granted_completer_fish.fish" 66 | 67 | // Try to create a file 68 | err := os.WriteFile(executableDir, []byte(combinedOutput), 0600) 69 | if err != nil { 70 | return fmt.Errorf("Something went wrong when saving fish autocompletions: " + err.Error()) 71 | } 72 | clio.Success("Fish autocompletions generated successfully") 73 | clio.Info("To use these completions please run the executable:") 74 | clio.Infof("source %s", executableDir) 75 | return nil 76 | } 77 | 78 | type AutoCompleteTemplateData struct { 79 | Program string 80 | } 81 | 82 | func installZSHCompletions(c *cli.Context) error { 83 | file, err := shells.GetZshConfigFile() 84 | if err != nil { 85 | return err 86 | } 87 | 88 | tmpl, err := template.ParseFS(templateFiles, "templates/*") 89 | if err != nil { 90 | return err 91 | } 92 | 93 | assumeData := AutoCompleteTemplateData{ 94 | Program: build.AssumeScriptName(), 95 | } 96 | assume := new(bytes.Buffer) 97 | err = tmpl.ExecuteTemplate(assume, "zsh_autocomplete_assume.tmpl", assumeData) 98 | if err != nil { 99 | return err 100 | } 101 | grantedData := AutoCompleteTemplateData{ 102 | Program: build.GrantedBinaryName(), 103 | } 104 | granted := new(bytes.Buffer) 105 | err = tmpl.ExecuteTemplate(granted, "zsh_autocomplete_granted.tmpl", grantedData) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | zshPathAssume, err := config.SetupZSHAutoCompleteFolderAssume() 111 | if err != nil { 112 | return err 113 | } 114 | 115 | err = os.WriteFile(path.Join(zshPathAssume, "_"+assumeData.Program), assume.Bytes(), 0666) 116 | if err != nil { 117 | return err 118 | } 119 | zshPathGranted, err := config.SetupZSHAutoCompleteFolderGranted() 120 | if err != nil { 121 | return err 122 | } 123 | err = os.WriteFile(path.Join(zshPathGranted, "_"+grantedData.Program), granted.Bytes(), 0666) 124 | if err != nil { 125 | return err 126 | } 127 | err = shells.AppendLine(file, fmt.Sprintf("fpath=(%s/ $fpath)", zshPathAssume)) 128 | var lae *shells.ErrLineAlreadyExists 129 | if is := errors.As(err, &lae); err != nil && !is { 130 | return err 131 | } 132 | err = shells.AppendLine(file, fmt.Sprintf("fpath=(%s/ $fpath)", zshPathGranted)) 133 | lae = nil 134 | if is := errors.As(err, &lae); err != nil && !is { 135 | return err 136 | } 137 | clio.Success("ZSH autocompletions generated successfully") 138 | clio.Warn("A shell restart is required to apply changes, please open a new terminal to test that autocomplete is working") 139 | return nil 140 | } 141 | 142 | func installBashCompletions(c *cli.Context) error { 143 | clio.Info("We don't have completion support for bash yet, check out our docs to find out how to let us know you want this feature https://granted.dev/autocompletion") 144 | return nil 145 | } 146 | -------------------------------------------------------------------------------- /pkg/urfav_overrides/flags.go: -------------------------------------------------------------------------------- 1 | package cfflags 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "io" 7 | "os" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | type Flags struct { 15 | FlagSet *flag.FlagSet 16 | urFavFlags []cli.Flag 17 | } 18 | 19 | // The purpose of this package is to allow the assume cli command to accept flags on either side of the "role" arg 20 | // for example, `assume -c my-role -region=us-east-1` by default, urfav-cli, the cli framework that we are using does not 21 | // support this usage pattern. 22 | // 23 | // We have extracted some methods from the original urfav-cli library to mimic the original behaviour but processing all the flags. 24 | // to use this in a command, 25 | // This package interacts with os.Args directly 26 | // 27 | // allFlags := cfflags.New("name",GlobalFlagsList, c) 28 | // allFlags.String("region") 29 | func New(name string, flags []cli.Flag, c *cli.Context) (*Flags, error) { 30 | set := flag.NewFlagSet(name, flag.ContinueOnError) 31 | for _, f := range flags { 32 | if err := f.Apply(set); err != nil { 33 | return nil, err 34 | } 35 | } 36 | 37 | set.SetOutput(io.Discard) 38 | 39 | ca := []string{} 40 | if c.Args().Len() > 1 { 41 | // append the flags excluding the role arg 42 | ca = append(ca, c.Args().Slice()[1:]...) 43 | } 44 | 45 | // context.Args() for this command will ONLY contain the role and any flags provided after the role 46 | // this slice of os.Args will only contain flags and not the role if it was provided 47 | ag := []string{} 48 | ag = append(ag, os.Args[1:len(os.Args)-c.Args().Len()]...) 49 | ag = append(ag, ca...) 50 | 51 | err := normalizeFlags(flags, set) 52 | if err != nil { 53 | return nil, err 54 | } 55 | err = set.Parse(ag) 56 | if err != nil { 57 | return nil, err 58 | } 59 | return &Flags{FlagSet: set, urFavFlags: flags}, nil 60 | } 61 | func copyFlag(name string, ff *flag.Flag, set *flag.FlagSet) { 62 | switch ff.Value.(type) { 63 | case cli.Serializer: 64 | _ = set.Set(name, ff.Value.(cli.Serializer).Serialize()) 65 | default: 66 | _ = set.Set(name, ff.Value.String()) 67 | } 68 | } 69 | 70 | func normalizeFlags(flags []cli.Flag, set *flag.FlagSet) error { 71 | visited := make(map[string]bool) 72 | set.Visit(func(f *flag.Flag) { 73 | visited[f.Name] = true 74 | }) 75 | for _, f := range flags { 76 | parts := f.Names() 77 | if len(parts) == 1 { 78 | continue 79 | } 80 | var ff *flag.Flag 81 | for _, name := range parts { 82 | name = strings.Trim(name, " ") 83 | if visited[name] { 84 | if ff != nil { 85 | return errors.New("Cannot use two forms of the same flag: " + name + " " + ff.Name) 86 | } 87 | ff = set.Lookup(name) 88 | } 89 | } 90 | if ff == nil { 91 | continue 92 | } 93 | for _, name := range parts { 94 | name = strings.Trim(name, " ") 95 | if !visited[name] { 96 | copyFlag(name, ff, set) 97 | } 98 | } 99 | } 100 | return nil 101 | } 102 | func (set *Flags) searchFS(name string) []string { 103 | for _, f := range set.urFavFlags { 104 | for _, n := range f.Names() { 105 | if n == name { 106 | return f.Names() 107 | } 108 | } 109 | } 110 | return nil 111 | } 112 | func (set *Flags) String(name string) string { 113 | names := set.searchFS(name) 114 | for _, n := range names { 115 | f := set.FlagSet.Lookup(n) 116 | if f != nil { 117 | parsed := f.Value.String() 118 | if parsed != "" { 119 | return parsed 120 | } 121 | } 122 | } 123 | return "" 124 | } 125 | 126 | func (set *Flags) StringSlice(name string) []string { 127 | names := set.searchFS(name) 128 | for _, n := range names { 129 | f := set.FlagSet.Lookup(n) 130 | if f != nil { 131 | parsed := f.Value.(*cli.StringSlice) 132 | return parsed.Value() 133 | } 134 | } 135 | return nil 136 | } 137 | 138 | func (set *Flags) Bool(name string) bool { 139 | names := set.searchFS(name) 140 | for _, n := range names { 141 | f := set.FlagSet.Lookup(n) 142 | if f != nil { 143 | parsed, _ := strconv.ParseBool(f.Value.String()) 144 | if parsed { 145 | return parsed 146 | } 147 | } 148 | } 149 | return false 150 | } 151 | 152 | func (set *Flags) Int(name string) int { 153 | names := set.searchFS(name) 154 | for _, n := range names { 155 | f := set.FlagSet.Lookup(n) 156 | if f != nil { 157 | parsed, err := strconv.ParseInt(f.Value.String(), 0, 64) 158 | if err != nil { 159 | return int(parsed) 160 | } 161 | } 162 | } 163 | return 0 164 | } 165 | 166 | func (set *Flags) Int64(name string) int64 { 167 | names := set.searchFS(name) 168 | for _, n := range names { 169 | f := set.FlagSet.Lookup(n) 170 | if f != nil { 171 | parsed, err := strconv.ParseInt(f.Value.String(), 0, 64) 172 | if err != nil { 173 | return parsed 174 | } 175 | } 176 | } 177 | return 0 178 | } 179 | -------------------------------------------------------------------------------- /pkg/cfaws/access_request_test.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/common-fate/clio/clierr" 7 | grantedConfig "github.com/common-fate/granted/pkg/config" 8 | "github.com/pkg/errors" 9 | "github.com/stretchr/testify/assert" 10 | "gopkg.in/ini.v1" 11 | ) 12 | 13 | func Test_parseURLFlagFromConfig(t *testing.T) { 14 | testFileContents := `[profile test1] 15 | credential_process = granted credential-process --url https://example.com 16 | 17 | [profile test2] 18 | credential_process = granted credential-process --url https://example.com 19 | 20 | [profile test3] 21 | credential_process = some-other-cli --url https://example.com 22 | 23 | [profile test4] 24 | ` 25 | testConfigFile, err := ini.LoadSources(ini.LoadOptions{}, []byte(testFileContents)) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | tests := []struct { 30 | name string 31 | profile string 32 | want string 33 | wantErr bool 34 | }{ 35 | { 36 | name: "ok", 37 | profile: "profile test1", 38 | want: "https://example.com", 39 | }, 40 | { 41 | name: "multiple spaces", 42 | profile: "profile test2", 43 | want: "https://example.com", 44 | }, 45 | { 46 | name: "other credential process", 47 | profile: "profile test3", 48 | want: "", 49 | }, 50 | { 51 | name: "no credential process entry", 52 | profile: "profile test4", 53 | want: "", 54 | }, 55 | } 56 | for _, tt := range tests { 57 | t.Run(tt.name, func(t *testing.T) { 58 | section, err := testConfigFile.GetSection(tt.profile) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | got := parseURLFlagFromConfig(section) 63 | if got != tt.want { 64 | t.Errorf("parseURLFlagFromConfig() = %v, want %v", got, tt.want) 65 | } 66 | }) 67 | } 68 | } 69 | 70 | func TestGetGrantedApprovalsURL(t *testing.T) { 71 | type args struct { 72 | rawConfig *ini.Section 73 | gConf grantedConfig.Config 74 | SSORoleName string 75 | SSOAccountId string 76 | } 77 | testFile := ini.Empty() 78 | 79 | emptySection, err := testFile.NewSection("empty") 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | section, err := testFile.NewSection("test") 84 | if err != nil { 85 | t.Fatal(err) 86 | } 87 | _, err = section.NewKey("credential_process", "granted credential-process --url https://override.example.com") 88 | if err != nil { 89 | t.Fatal(err) 90 | } 91 | tests := []struct { 92 | name string 93 | args args 94 | want *clierr.Err 95 | wantErr bool 96 | }{ 97 | { 98 | name: "ok", 99 | args: args{ 100 | gConf: grantedConfig.Config{ 101 | AccessRequestURL: "https://example.com", 102 | }, 103 | SSORoleName: "test", 104 | SSOAccountId: "123456789012", 105 | rawConfig: emptySection, 106 | }, 107 | 108 | want: &clierr.Err{ 109 | Err: "test error", 110 | Messages: []clierr.Printer{ 111 | clierr.Warn("You need to request access to this role:"), 112 | clierr.Warn("https://example.com/access?accountId=123456789012&permissionSetArn.label=test&type=commonfate%2Faws-sso"), 113 | clierr.Warn("or run: 'granted exp request latest'"), 114 | }, 115 | }, 116 | }, 117 | { 118 | name: "url flag precedence", 119 | args: args{ 120 | gConf: grantedConfig.Config{ 121 | AccessRequestURL: "https://example.com", 122 | }, 123 | rawConfig: section, 124 | SSORoleName: "test", 125 | SSOAccountId: "123456789012", 126 | }, 127 | want: &clierr.Err{ 128 | Err: "test error", 129 | Messages: []clierr.Printer{ 130 | clierr.Warn("You need to request access to this role:"), 131 | clierr.Warn("https://override.example.com/access?accountId=123456789012&permissionSetArn.label=test&type=commonfate%2Faws-sso"), 132 | clierr.Warn("or run: 'granted exp request latest'"), 133 | }, 134 | }, 135 | }, 136 | { 137 | name: "display prompt if no URL is set", 138 | args: args{ 139 | gConf: grantedConfig.Config{}, 140 | rawConfig: emptySection, 141 | }, 142 | want: &clierr.Err{ 143 | Err: "test error", 144 | Messages: []clierr.Printer{ 145 | clierr.Info("It looks like you don't have the right permissions to access this role"), 146 | clierr.Info("If you are using Common Fate to manage this role you can configure the Granted CLI with a request URL so that you can be directed to your Granted Approvals instance to make a new access request the next time you have this error"), 147 | clierr.Info("To configure a URL to request access to this role with 'granted settings request-url set "${expiry}" ]] 113 | } 114 | 115 | granted_auto_reassume() { 116 | # Nothing to do, we can't reassume a profile that we don't know. 117 | if [[ -z "${AWS_PROFILE}" ]]; then return 0; fi 118 | 119 | if ! _is_assume_expired; then return 0; fi 120 | 121 | if [[ "${GRANTED_QUIET:-}" != "true" ]] 122 | then 123 | echo "granted session expired; reassuming ${AWS_PROFILE}." >&2 124 | fi 125 | assume "${AWS_PROFILE}" 126 | } 127 | 128 | if [[ -n "${ZSH_NAME:-}" && "${GRANTED_ENABLE_AUTO_REASSUME:-}" = "true" ]] 129 | then 130 | # shellcheck disable=SC2154 131 | if ! [[ " ${preexec_functions[*]} " =~ " granted_auto_reassume " ]] 132 | then 133 | autoload -Uz add-zsh-hook 134 | add-zsh-hook preexec granted_auto_reassume 135 | fi 136 | fi 137 | 138 | 139 | # The GrantedOutput flag should be followed by a newline, then the output. 140 | # This way, the shell script can omit the first line containing the flag and return the unaltered output to the stdout 141 | # This is great as it works well with the -exec flag 142 | if [ "$GRANTED_FLAG" = "GrantedOutput" ];then 143 | echo "${GRANTED_OUTPUT}" | sed -n '1!p' 144 | fi 145 | 146 | if [ "$GRANTED_RETURN_STATUS" = "true" ]; then 147 | return $GRANTED_STATUS 148 | fi 149 | -------------------------------------------------------------------------------- /pkg/browser/browsers.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "errors" 5 | "os/exec" 6 | "runtime" 7 | ) 8 | 9 | // A browser supported by Granted. 10 | const ( 11 | ChromeKey string = "CHROME" 12 | BraveKey string = "BRAVE" 13 | EdgeKey string = "EDGE" 14 | FirefoxKey string = "FIREFOX" 15 | ChromiumKey string = "CHROMIUM" 16 | SafariKey string = "SAFARI" 17 | StdoutKey string = "STDOUT" 18 | FirefoxStdoutKey string = "FIREFOX_STDOUT" 19 | ) 20 | 21 | // A few default paths to check for the browser 22 | var ChromePathMac = []string{"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"} 23 | var ChromePathLinux = []string{`/usr/bin/google-chrome`, `/../../mnt/c/Program Files/Google/Chrome/Application/chrome.exe`, `/../../mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe`} 24 | var ChromePathWindows = []string{`\Program Files\Google\Chrome\Application\chrome.exe`, `\Program Files (x86)\Google\Chrome\Application\chrome.exe`} 25 | 26 | var BravePathMac = []string{"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"} 27 | var BravePathLinux = []string{`/usr/bin/brave-browser`, `/../../mnt/c/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe`} 28 | var BravePathWindows = []string{`\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`} 29 | 30 | var EdgePathMac = []string{"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"} 31 | var EdgePathLinux = []string{`/usr/bin/edge`, `/../../mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe`} 32 | var EdgePathWindows = []string{`\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`} 33 | 34 | var FirefoxPathMac = []string{"/Applications/Firefox.app/Contents/MacOS/firefox"} 35 | var FirefoxPathLinux = []string{`/usr/bin/firefox`, `/../../mnt/c/Program Files/Mozilla Firefox/firefox.exe`} 36 | var FirefoxPathWindows = []string{`\Program Files\Mozilla Firefox\firefox.exe`} 37 | 38 | var ChromiumPathMac = []string{"/Applications/Chromium.app/Contents/MacOS/Chromium"} 39 | var ChromiumPathLinux = []string{`/usr/bin/chromium`, `/../../mnt/c/Program Files/Chromium/chromium.exe`} 40 | var ChromiumPathWindows = []string{`\Program Files\Chromium\chromium.exe`} 41 | 42 | var SafariPathMac = []string{"/Applications/Safari.app/Contents/MacOS/Safari"} 43 | 44 | func ChromePathDefaults() ([]string, error) { 45 | // check linuxpath for binary install 46 | path, err := exec.LookPath("google-chrome-stable") 47 | if err != nil { 48 | path, err = exec.LookPath("google-chrome") 49 | if err == nil { 50 | return []string{path}, nil 51 | } 52 | } 53 | if err == nil { 54 | return []string{path}, nil 55 | } 56 | switch runtime.GOOS { 57 | case "windows": 58 | return ChromePathWindows, nil 59 | case "darwin": 60 | return ChromePathMac, nil 61 | case "linux": 62 | return ChromePathLinux, nil 63 | default: 64 | return nil, errors.New("os not supported") 65 | } 66 | } 67 | 68 | func BravePathDefaults() ([]string, error) { 69 | // check linuxpath for binary install 70 | path, err := exec.LookPath("brave") 71 | if err == nil { 72 | return []string{path}, nil 73 | } 74 | switch runtime.GOOS { 75 | case "windows": 76 | return BravePathWindows, nil 77 | case "darwin": 78 | return BravePathMac, nil 79 | case "linux": 80 | return BravePathLinux, nil 81 | default: 82 | return nil, errors.New("os not supported") 83 | } 84 | } 85 | 86 | func EdgePathDefaults() ([]string, error) { 87 | // check linuxpath for binary install 88 | path, err := exec.LookPath("edge") 89 | if err == nil { 90 | return []string{path}, nil 91 | } 92 | switch runtime.GOOS { 93 | case "windows": 94 | return EdgePathWindows, nil 95 | case "darwin": 96 | return EdgePathMac, nil 97 | case "linux": 98 | return EdgePathLinux, nil 99 | default: 100 | return nil, errors.New("os not supported") 101 | } 102 | } 103 | 104 | func FirefoxPathDefaults() ([]string, error) { 105 | // check linuxpath for binary install 106 | path, err := exec.LookPath("firefox") 107 | if err == nil { 108 | return []string{path}, nil 109 | } 110 | switch runtime.GOOS { 111 | case "windows": 112 | return FirefoxPathWindows, nil 113 | case "darwin": 114 | return FirefoxPathMac, nil 115 | case "linux": 116 | return FirefoxPathLinux, nil 117 | default: 118 | return nil, errors.New("os not supported") 119 | } 120 | } 121 | 122 | func ChromiumPathDefaults() ([]string, error) { 123 | // check linuxpath for binary install 124 | path, err := exec.LookPath("chromium") 125 | if err == nil { 126 | return []string{path}, nil 127 | } 128 | switch runtime.GOOS { 129 | case "windows": 130 | return ChromiumPathWindows, nil 131 | case "darwin": 132 | return ChromiumPathMac, nil 133 | case "linux": 134 | return ChromiumPathLinux, nil 135 | default: 136 | return nil, errors.New("os not supported") 137 | } 138 | } 139 | 140 | func SafariPathDefaults() ([]string, error) { 141 | switch runtime.GOOS { 142 | case "darwin": 143 | return SafariPathMac, nil 144 | default: 145 | return nil, errors.New("os not supported") 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /pkg/cfaws/granted_config.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/aws/aws-sdk-go-v2/config" 11 | "github.com/common-fate/clio" 12 | "gopkg.in/ini.v1" 13 | ) 14 | 15 | func ParseGrantedSSOProfile(ctx context.Context, profile *Profile) (*config.SharedConfig, error) { 16 | err := IsValidGrantedProfile(profile) 17 | if err != nil { 18 | return nil, err 19 | } 20 | cfg, err := config.LoadSharedConfigProfile(ctx, profile.Name, func(lsco *config.LoadSharedConfigOptions) { lsco.ConfigFiles = []string{profile.File} }) 21 | if err != nil { 22 | return nil, err 23 | } 24 | item, err := profile.RawConfig.GetKey("granted_sso_account_id") 25 | if err != nil { 26 | return nil, err 27 | } 28 | cfg.SSOAccountID = item.Value() 29 | item, err = profile.RawConfig.GetKey("granted_sso_region") 30 | if err != nil { 31 | if profile.SSOSession != nil && profile.SSOSession.SSORegion != "" { 32 | cfg.SSORegion = profile.SSOSession.SSORegion 33 | } else { 34 | return nil, err 35 | } 36 | } else { 37 | cfg.SSORegion = item.Value() 38 | } 39 | 40 | item, err = profile.RawConfig.GetKey("granted_sso_role_name") 41 | if err != nil { 42 | return nil, err 43 | } 44 | cfg.SSORoleName = item.Value() 45 | 46 | item, err = profile.RawConfig.GetKey("granted_sso_start_url") 47 | if err != nil { 48 | if profile.SSOSession != nil && profile.SSOSession.SSORegion != "" { 49 | cfg.SSOStartURL = profile.SSOSession.SSOStartURL 50 | } else { 51 | return nil, err 52 | } 53 | } else { 54 | cfg.SSOStartURL = item.Value() 55 | } 56 | 57 | // sanity check to verify if the provided value is a valid url 58 | _, err = url.ParseRequestURI(cfg.SSOStartURL) 59 | 60 | // normalize the url to remove trailing slashes if they exist 61 | cfg.SSOStartURL = strings.TrimSuffix(cfg.SSOStartURL, "/") 62 | 63 | if err != nil { 64 | clio.Debug(err) 65 | return nil, fmt.Errorf("invalid value '%s' provided for 'granted_sso_start_url'", cfg.SSOStartURL) 66 | } 67 | 68 | item, err = profile.RawConfig.GetKey("credential_process") 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | err = validateCredentialProcess(item.Value(), profile.Name) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | return &cfg, err 79 | } 80 | 81 | // For `granted login` cmd, we have to make sure 'granted' prefix 82 | // is added to the aws config file. 83 | func IsValidGrantedProfile(profile *Profile) error { 84 | requiredGrantedCredentials := []string{"granted_sso_account_id", "granted_sso_role_name"} //"granted_sso_start_url", "granted_sso_region", 85 | for _, value := range requiredGrantedCredentials { 86 | if !profile.RawConfig.HasKey(value) { 87 | return fmt.Errorf("invalid aws config for granted login. '%s' field must be provided", value) 88 | } 89 | } 90 | if profile.SSOSession != nil { 91 | if profile.SSOSession.SSORegion == "" && !profile.RawConfig.HasKey("granted_sso_region") { 92 | return fmt.Errorf("invalid aws config for granted login. '%s' field must be provided", "granted_sso_region") 93 | } 94 | if profile.SSOSession.SSOStartURL == "" && !profile.RawConfig.HasKey("granted_sso_start_url") { 95 | return fmt.Errorf("invalid aws config for granted login. '%s' field must be provided", "granted_sso_start_url") 96 | } 97 | } 98 | return nil 99 | } 100 | 101 | // check if the config section has any keys prefixed with "granted_sso_" 102 | func hasGrantedSSOPrefix(rawConfig *ini.Section) bool { 103 | for _, v := range rawConfig.KeyStrings() { 104 | if strings.HasPrefix(v, "granted_sso_") { 105 | return true 106 | } 107 | } 108 | return false 109 | } 110 | 111 | // validateCredentialProcess checks whether the granted_ prefixed AWS profiles 112 | // are correctly using the granted credential-process override or not. 113 | // also check whether the provided flag to 'granted credential-process --profile pname' 114 | // matches the AWS config profile name. If it doesn't then return an err 115 | // as the user will certainly run into unexpected behaviour. 116 | func validateCredentialProcess(arg string, awsProfileName string) error { 117 | regex := regexp.MustCompile(`^(\s+)?(dgranted|granted)\s+credential-process.*--profile\s+(?P([^\s]+))`) 118 | 119 | if regex.MatchString(arg) { 120 | matches := regex.FindStringSubmatch(arg) 121 | pNameIndex := regex.SubexpIndex("PName") 122 | 123 | profileName := matches[pNameIndex] 124 | 125 | if profileName == "" { 126 | return fmt.Errorf("profile name not provided. Try adding profile name like 'granted credential-process --profile '") 127 | } 128 | 129 | // if matches then do nothing. 130 | if profileName == awsProfileName { 131 | return nil 132 | } 133 | 134 | return fmt.Errorf("unmatched profile names. The profile name '%s' provided to 'granted credential-process' does not match AWS profile name '%s'", profileName, awsProfileName) 135 | } 136 | 137 | return fmt.Errorf("unable to parse 'credential_process'. Looks like your credential_process isn't configured correctly. \n You need to add 'granted credential-process --profile '") 138 | } 139 | -------------------------------------------------------------------------------- /pkg/cfaws/assumer_aws_iam.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | "github.com/aws/aws-sdk-go-v2/config" 8 | "github.com/aws/aws-sdk-go-v2/credentials/stscreds" 9 | "github.com/aws/aws-sdk-go-v2/service/sts" 10 | "github.com/common-fate/clio" 11 | "github.com/common-fate/granted/pkg/securestorage" 12 | "gopkg.in/ini.v1" 13 | ) 14 | 15 | // Implements Assumer 16 | type AwsIamAssumer struct { 17 | } 18 | 19 | // Default behaviour is to use the sdk to retrieve the credentials from the file 20 | // For launching the console there is an extra step GetFederationToken that happens after this to get a session token 21 | func (aia *AwsIamAssumer) AssumeTerminal(ctx context.Context, c *Profile, configOpts ConfigOpts) (aws.Credentials, error) { 22 | if c.HasSecureStorageIAMCredentials { 23 | secureIAMCredentialStorage := securestorage.NewSecureIAMCredentialStorage() 24 | return secureIAMCredentialStorage.GetCredentials(c.Name) 25 | } 26 | 27 | //using ~/.aws/credentials file for creds 28 | opts := []func(*config.LoadOptions) error{ 29 | // load the config profile 30 | config.WithSharedConfigProfile(c.Name), 31 | config.WithAssumeRoleCredentialOptions(func(aro *stscreds.AssumeRoleOptions) { 32 | // set the token provider up 33 | aro.TokenProvider = MfaTokenProvider 34 | aro.Duration = configOpts.Duration 35 | 36 | // If the mfa_serial is defined on the root profile, we need to set it in this config so that the aws SDK knows to prompt for MFA token 37 | if len(c.Parents) > 0 { 38 | if c.Parents[0].AWSConfig.MFASerial != "" { 39 | aro.SerialNumber = aws.String(c.Parents[0].AWSConfig.MFASerial) 40 | 41 | } 42 | } 43 | if c.AWSConfig.RoleSessionName != "" { 44 | aro.RoleSessionName = c.AWSConfig.RoleSessionName 45 | } else { 46 | aro.RoleSessionName = sessionName() 47 | } 48 | }), 49 | } 50 | 51 | // load the creds from the credentials file 52 | cfg, err := config.LoadDefaultConfig(ctx, opts...) 53 | if err != nil { 54 | return aws.Credentials{}, err 55 | } 56 | 57 | credentials, err := aws.NewCredentialsCache(cfg.Credentials).Retrieve(ctx) 58 | if err != nil { 59 | return aws.Credentials{}, err 60 | } 61 | 62 | // inform the user about using the secure storage to securely store IAM user credentials 63 | // if it has no parents and it reached this point, it must have had plain text credentials 64 | // if it has parents, and the root is not a secure storage iam profile, then it has plain text credentials 65 | if len(c.Parents) == 0 || !c.Parents[0].HasSecureStorageIAMCredentials { 66 | clio.Warnf("Profile %s has plaintext credentials stored in the AWS credentials file", c.Name) 67 | clio.Infof("To move the credentials to secure storage, run 'granted credentials import %s'", c.Name) 68 | } 69 | 70 | return credentials, nil 71 | 72 | } 73 | 74 | // if required will get a FederationToken to be used to launch the console 75 | // This is required if the iam profile does not assume a role using sts.AssumeRole 76 | func (aia *AwsIamAssumer) AssumeConsole(ctx context.Context, c *Profile, configOpts ConfigOpts) (aws.Credentials, error) { 77 | if c.AWSConfig.Credentials.SessionToken != "" { 78 | clio.Debug("found existing session token in credentials for IAM profile, using this to launch the console") 79 | return c.AWSConfig.Credentials, nil 80 | } else if c.AWSConfig.RoleARN == "" { 81 | return getFederationToken(ctx, c) 82 | } else { 83 | // profile assume a role 84 | return aia.AssumeTerminal(ctx, c, configOpts) 85 | } 86 | } 87 | 88 | // A unique key which identifies this assumer e.g AWS-SSO or GOOGLE-AWS-AUTH 89 | func (aia *AwsIamAssumer) Type() string { 90 | return "AWS_IAM" 91 | } 92 | 93 | // Matches the profile type on whether it is not an sso profile. 94 | // this will also match other types that are not sso profiles so it should be the last option checked when determining the profile type 95 | func (aia *AwsIamAssumer) ProfileMatchesType(rawProfile *ini.Section, parsedProfile config.SharedConfig) bool { 96 | return parsedProfile.SSOAccountID == "" 97 | } 98 | 99 | var allowAllPolicy = `{ 100 | "Version": "2012-10-17", 101 | "Statement": [ 102 | { 103 | "Sid": "AllowAll", 104 | "Effect": "Allow", 105 | "Action": "*", 106 | "Resource": "*" 107 | } 108 | ] 109 | }` 110 | 111 | // GetFederationToken is used when launching a console session with long-lived IAM credentials profiles 112 | // GetFederation token uses an allow all IAM policy so that the console session will be able to access everything 113 | // If this is not provided, the session cannot do anything in the console 114 | func getFederationToken(ctx context.Context, c *Profile) (aws.Credentials, error) { 115 | opts := []func(*config.LoadOptions) error{ 116 | // load the config profile 117 | config.WithSharedConfigProfile(c.Name), 118 | } 119 | 120 | // load the creds from the credentials file 121 | cfg, err := config.LoadDefaultConfig(ctx, opts...) 122 | if err != nil { 123 | return aws.Credentials{}, err 124 | } 125 | 126 | client := sts.NewFromConfig(cfg) 127 | 128 | out, err := client.GetFederationToken(ctx, &sts.GetFederationTokenInput{Name: aws.String(sessionName()), Policy: aws.String(allowAllPolicy)}) 129 | 130 | if err != nil { 131 | return aws.Credentials{}, err 132 | } 133 | return TypeCredsToAwsCreds(*out.Credentials), err 134 | 135 | } 136 | -------------------------------------------------------------------------------- /pkg/granted/console.go: -------------------------------------------------------------------------------- 1 | package granted 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "os/exec" 8 | "path" 9 | 10 | "github.com/common-fate/clio" 11 | "github.com/common-fate/clio/clierr" 12 | "github.com/common-fate/granted/pkg/assume" 13 | "github.com/common-fate/granted/pkg/browser" 14 | "github.com/common-fate/granted/pkg/cfaws" 15 | "github.com/common-fate/granted/pkg/config" 16 | "github.com/common-fate/granted/pkg/console" 17 | "github.com/common-fate/granted/pkg/forkprocess" 18 | "github.com/common-fate/granted/pkg/launcher" 19 | "github.com/urfave/cli/v2" 20 | ) 21 | 22 | var ConsoleCommand = cli.Command{ 23 | Name: "console", 24 | Usage: "Generate an AWS console URL using credentials in the environment", 25 | Flags: []cli.Flag{ 26 | 27 | &cli.StringFlag{Name: "service"}, 28 | &cli.StringFlag{Name: "region", EnvVars: []string{"AWS_REGION"}}, 29 | &cli.StringFlag{Name: "destination", Usage: "The destination URL for the console"}, 30 | &cli.BoolFlag{Name: "url", Usage: "Return the URL to stdout instead of launching the browser"}, 31 | &cli.BoolFlag{Name: "firefox", Usage: "Generate the Firefox container URL"}, 32 | &cli.StringFlag{Name: "color", Usage: "When the firefox flag is true, this specifies the color of the container tab"}, 33 | &cli.StringFlag{Name: "icon", Usage: "When firefox flag is true, this specifies the icon of the container tab"}, 34 | &cli.StringFlag{Name: "container-name", Usage: "When firefox flag is true, this specifies the name of the container of the container tab.", Value: "aws"}, 35 | }, 36 | Action: func(c *cli.Context) error { 37 | ctx := c.Context 38 | credentials := cfaws.GetEnvCredentials(ctx) 39 | con := console.AWS{ 40 | Service: c.String("service"), 41 | Region: c.String("region"), 42 | Destination: c.String("destination"), 43 | } 44 | 45 | consoleURL, err := con.URL(credentials) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | cfg, err := config.Load() 51 | if err != nil { 52 | return err 53 | } 54 | if c.Bool("firefox") || cfg.DefaultBrowser == browser.FirefoxKey || cfg.DefaultBrowser == browser.FirefoxStdoutKey { 55 | // transform the URL into the Firefox Tab Container format. 56 | consoleURL = fmt.Sprintf("ext+granted-containers:name=%s&url=%s&color=%s&icon=%s", c.String("container-name"), url.QueryEscape(consoleURL), c.String("color"), c.String("icon")) 57 | } 58 | 59 | justPrintURL := c.Bool("url") || cfg.DefaultBrowser == browser.StdoutKey || cfg.DefaultBrowser == browser.FirefoxStdoutKey 60 | if justPrintURL { 61 | // return the url via stdout through the CLI wrapper script and return early. 62 | fmt.Print(consoleURL) 63 | return nil 64 | } 65 | 66 | grantedFolder, err := config.GrantedConfigFolder() 67 | if err != nil { 68 | return err 69 | } 70 | 71 | var l assume.Launcher 72 | if cfg.CustomBrowserPath == "" && cfg.DefaultBrowser != "" { 73 | l = launcher.Open{} 74 | } else if cfg.CustomBrowserPath == "" { 75 | return errors.New("default browser not configured. run `granted browser set` to configure") 76 | } else { 77 | switch cfg.DefaultBrowser { 78 | case browser.ChromeKey: 79 | l = launcher.ChromeProfile{ 80 | ExecutablePath: cfg.CustomBrowserPath, 81 | UserDataPath: path.Join(grantedFolder, "chromium-profiles", "1"), // held over for backwards compatibility, "1" indicates Chrome profiles 82 | } 83 | case browser.BraveKey: 84 | l = launcher.ChromeProfile{ 85 | ExecutablePath: cfg.CustomBrowserPath, 86 | UserDataPath: path.Join(grantedFolder, "chromium-profiles", "2"), // held over for backwards compatibility, "2" indicates Brave profiles 87 | } 88 | case browser.EdgeKey: 89 | l = launcher.ChromeProfile{ 90 | ExecutablePath: cfg.CustomBrowserPath, 91 | UserDataPath: path.Join(grantedFolder, "chromium-profiles", "3"), // held over for backwards compatibility, "3" indicates Edge profiles 92 | } 93 | case browser.ChromiumKey: 94 | l = launcher.ChromeProfile{ 95 | ExecutablePath: cfg.CustomBrowserPath, 96 | UserDataPath: path.Join(grantedFolder, "chromium-profiles", "4"), // held over for backwards compatibility, "4" indicates Chromium profiles 97 | } 98 | case browser.FirefoxKey: 99 | l = launcher.Firefox{ 100 | ExecutablePath: cfg.CustomBrowserPath, 101 | } 102 | case browser.SafariKey: 103 | l = launcher.Safari{} 104 | default: 105 | l = launcher.Open{} 106 | } 107 | } 108 | // now build the actual command to run - e.g. 'firefox --new-tab ' 109 | args := l.LaunchCommand(consoleURL, con.Profile) 110 | 111 | var startErr error 112 | if l.UseForkProcess() { 113 | clio.Debugf("running command using forkprocess: %s", args) 114 | cmd, err := forkprocess.New(args...) 115 | if err != nil { 116 | return err 117 | } 118 | startErr = cmd.Start() 119 | } else { 120 | clio.Debugf("running command without forkprocess: %s", args) 121 | cmd := exec.Command(args[0], args[1:]...) 122 | startErr = cmd.Start() 123 | } 124 | 125 | if startErr != nil { 126 | return clierr.New(fmt.Sprintf("Granted was unable to open a browser session automatically due to the following error: %s", err.Error()), 127 | // allow them to try open the url manually 128 | clierr.Info("You can open the browser session manually using the following url:"), 129 | clierr.Info(consoleURL), 130 | ) 131 | } 132 | return nil 133 | }, 134 | } 135 | -------------------------------------------------------------------------------- /pkg/granted/registry/sync.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/common-fate/clio" 8 | grantedConfig "github.com/common-fate/granted/pkg/config" 9 | "github.com/urfave/cli/v2" 10 | "gopkg.in/ini.v1" 11 | ) 12 | 13 | var SyncCommand = cli.Command{ 14 | Name: "sync", 15 | Usage: "Pull the latest change from remote origin and sync aws profiles in aws config files", 16 | Description: "Pull the latest change from remote origin and sync aws profiles in aws config files", 17 | Action: func(c *cli.Context) error { 18 | if err := SyncProfileRegistries(false, true, false); err != nil { 19 | return err 20 | } 21 | 22 | return nil 23 | }, 24 | } 25 | 26 | type syncOpts struct { 27 | isFirstSection bool 28 | promptUserIfProfileDuplication bool 29 | shouldSilentLog bool 30 | shouldFailForRequiredKeys bool 31 | } 32 | 33 | // Wrapper around sync func. Check if profile registry is configured, pull the latest changes and call sync func. 34 | // promptUserIfProfileDuplication if true will automatically prefix the duplicate profiles and won't prompt users 35 | // this is useful when new registry with higher priority is added and there is duplication with lower priority registry. 36 | func SyncProfileRegistries(shouldSilentLog bool, promptUserIfProfileDuplication bool, shouldFailForRequiredKeys bool) error { 37 | registries, err := GetProfileRegistries() 38 | if err != nil { 39 | return err 40 | } 41 | 42 | if len(registries) == 0 { 43 | clio.Warn("granted registry not configured. Try adding a git repository with 'granted registry add '") 44 | } 45 | 46 | configFile, awsConfigPath, err := loadAWSConfigFile() 47 | if err != nil { 48 | return err 49 | } 50 | 51 | // if the config file contains granted generated content then remove it 52 | if err := removeAutogeneratedProfiles(configFile, awsConfigPath); err != nil { 53 | return err 54 | } 55 | 56 | for index, r := range registries { 57 | isFirstSection := false 58 | if index == 0 { 59 | isFirstSection = true 60 | } 61 | 62 | err = runSync(&r, configFile, awsConfigPath, syncOpts{ 63 | isFirstSection: isFirstSection, 64 | shouldSilentLog: shouldSilentLog, 65 | promptUserIfProfileDuplication: promptUserIfProfileDuplication, 66 | shouldFailForRequiredKeys: shouldFailForRequiredKeys, 67 | }) 68 | 69 | if err != nil { 70 | se, ok := err.(*SyncError) 71 | if ok { 72 | clio.Warnf("Sync failed for registry %s", r.Config.Name) 73 | clio.Debug(se.Error()) 74 | 75 | // skip syncing for this registry but continue syncing for other registries. 76 | continue 77 | } 78 | return err 79 | } 80 | } 81 | 82 | return nil 83 | } 84 | 85 | // runSync will return custom error when there is error for specific registry so that 86 | // other registries can still be synced. 87 | func runSync(r *Registry, configFile *ini.File, configFilePath string, opts syncOpts) error { 88 | repoDirPath, err := getRegistryLocation(r.Config) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | // If the local repo has been deleted, then attempt to clone it again 94 | _, err = os.Stat(repoDirPath) 95 | if os.IsNotExist(err) { 96 | err = gitClone(r.Config.URL, repoDirPath) 97 | if err != nil { 98 | return &SyncError{ 99 | RegistryName: r.Config.Name, 100 | Err: err, 101 | } 102 | } 103 | } else { 104 | err = gitPull(repoDirPath, opts.shouldSilentLog) 105 | if err != nil { 106 | return &SyncError{ 107 | RegistryName: r.Config.Name, 108 | Err: err, 109 | } 110 | } 111 | } 112 | 113 | err = r.Parse() 114 | if err != nil { 115 | return &SyncError{ 116 | RegistryName: r.Config.Name, 117 | Err: err, 118 | } 119 | } 120 | 121 | err = r.PromptRequiredKeys([]string{}, opts.shouldFailForRequiredKeys) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | if err := Sync(r, configFile, opts); err != nil { 127 | return err 128 | } 129 | 130 | err = configFile.SaveTo(configFilePath) 131 | if err != nil { 132 | return err 133 | } 134 | 135 | return nil 136 | } 137 | 138 | // when there is new duplication when running sync command 139 | // and if user chooses to duplicate then currently the config is not saved to gconfig. 140 | 141 | // Sync function will load all the configs provided in the clonedFile. 142 | // and generate a new section in the ~/.aws/profile file. 143 | func Sync(r *Registry, awsConfigFile *ini.File, opts syncOpts) error { 144 | clio.Debugf("syncing %s \n", r.Config.Name) 145 | 146 | clonedFile, err := loadClonedConfigs(*r) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | gconf, err := grantedConfig.Load() 152 | if err != nil { 153 | return err 154 | } 155 | 156 | // return custom error that should be caught and skipped. 157 | err = generateNewRegistrySection(r, awsConfigFile, clonedFile, gconf, opts) 158 | if err != nil { 159 | return &SyncError{ 160 | Err: err, 161 | RegistryName: r.Config.Name, 162 | } 163 | } 164 | 165 | clio.Successf("Successfully synced registry %s", r.Config.Name) 166 | 167 | return nil 168 | } 169 | 170 | type SyncError struct { 171 | Err error 172 | RegistryName string 173 | } 174 | 175 | func (m *SyncError) Error() string { 176 | return fmt.Sprintf("Failed to sync for registry %s with error: %s", m.RegistryName, m.Err.Error()) 177 | } 178 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/common-fate/granted 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/99designs/keyring v1.2.2 7 | github.com/AlecAivazis/survey/v2 v2.3.6 8 | github.com/aws/aws-sdk-go-v2 v1.17.7 9 | github.com/aws/aws-sdk-go-v2/config v1.18.19 10 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 11 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 12 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 13 | github.com/common-fate/updatecheck v0.3.4 14 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 15 | github.com/pkg/errors v0.9.1 16 | github.com/segmentio/ksuid v1.0.4 17 | github.com/urfave/cli/v2 v2.24.1 18 | ) 19 | 20 | require ( 21 | github.com/briandowns/spinner v1.23.0 22 | github.com/common-fate/cli v0.4.3 23 | github.com/common-fate/clio v1.1.0 24 | github.com/common-fate/common-fate v0.15.0 25 | github.com/fatih/color v1.13.0 26 | github.com/lithammer/fuzzysearch v1.1.5 27 | go.uber.org/zap v1.23.0 28 | gopkg.in/yaml.v3 v3.0.1 29 | ) 30 | 31 | require ( 32 | github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect 33 | github.com/TylerBrock/saw v0.2.2 // indirect 34 | github.com/aws-cloudformation/rain v1.2.0 // indirect 35 | github.com/aws/aws-sdk-go v1.44.71 // indirect 36 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect 37 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23 // indirect 38 | github.com/aws/aws-sdk-go-v2/service/cloudformation v1.22.2 // indirect 39 | github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.15.10 // indirect 40 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect 41 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.26 // indirect 42 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.0 // indirect 43 | github.com/aws/aws-sdk-go-v2/service/lambda v1.30.0 // indirect 44 | github.com/aws/aws-sdk-go-v2/service/s3 v1.31.1 // indirect 45 | github.com/aws/aws-sdk-go-v2/service/ssm v1.28.0 // indirect 46 | github.com/chzyer/readline v1.5.1 // indirect 47 | github.com/common-fate/apikit v0.2.1-0.20220526131641-1d860b34f6ed // indirect 48 | github.com/common-fate/cloudform v0.6.0 // indirect 49 | github.com/common-fate/iso8601 v1.0.2 // indirect 50 | github.com/common-fate/provider-registry-sdk-go v0.17.5 // indirect 51 | github.com/davecgh/go-spew v1.1.1 // indirect 52 | github.com/deepmap/oapi-codegen v1.11.0 // indirect 53 | github.com/getkin/kin-openapi v0.107.0 // indirect 54 | github.com/go-chi/chi/v5 v5.0.7 // indirect 55 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 56 | github.com/go-openapi/swag v0.22.0 // indirect 57 | github.com/golang/protobuf v1.5.2 // indirect 58 | github.com/google/uuid v1.3.0 // indirect 59 | github.com/gookit/color v1.5.1 // indirect 60 | github.com/invopop/yaml v0.2.0 // indirect 61 | github.com/jmespath/go-jmespath v0.4.0 // indirect 62 | github.com/josharian/intern v1.0.0 // indirect 63 | github.com/mailru/easyjson v0.7.7 // indirect 64 | github.com/mattn/go-runewidth v0.0.14 // indirect 65 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 66 | github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d // indirect 67 | github.com/pmezard/go-difflib v1.0.0 // indirect 68 | github.com/rivo/uniseg v0.3.4 // indirect 69 | github.com/sethvargo/go-retry v0.2.4 // indirect 70 | github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect 71 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 72 | go.uber.org/atomic v1.9.0 // indirect 73 | go.uber.org/multierr v1.8.0 // indirect 74 | golang.org/x/net v0.7.0 // indirect 75 | golang.org/x/oauth2 v0.4.0 // indirect 76 | google.golang.org/appengine v1.6.7 // indirect 77 | google.golang.org/protobuf v1.28.1 // indirect 78 | ) 79 | 80 | require ( 81 | github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect 82 | github.com/BurntSushi/toml v1.2.1 83 | github.com/aws/aws-sdk-go-v2/credentials v1.13.18 84 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 // indirect 85 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 // indirect 86 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 // indirect 87 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 // indirect 88 | github.com/aws/aws-sdk-go-v2/service/iam v1.18.25 89 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 // indirect 90 | github.com/aws/smithy-go v1.13.5 91 | github.com/common-fate/awsconfigfile v0.8.0 92 | github.com/common-fate/useragent v0.1.0 93 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 94 | github.com/danieljoos/wincred v1.1.2 // indirect 95 | github.com/dvsekhvalnov/jose2go v1.5.0 // indirect 96 | github.com/fatih/structs v1.1.0 97 | github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect 98 | github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect 99 | github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b 100 | github.com/joho/godotenv v1.4.0 101 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 102 | github.com/mattn/go-colorable v0.1.13 // indirect 103 | github.com/mattn/go-isatty v0.0.17 // indirect 104 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 105 | github.com/mtibben/percent v0.2.1 // indirect 106 | github.com/olekukonko/tablewriter v0.0.5 107 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 108 | github.com/stretchr/testify v1.8.1 109 | golang.org/x/sync v0.1.0 110 | golang.org/x/sys v0.5.0 111 | golang.org/x/term v0.5.0 // indirect 112 | golang.org/x/text v0.7.0 113 | gopkg.in/ini.v1 v1.67.0 114 | ) 115 | -------------------------------------------------------------------------------- /pkg/assume/entrypoint.go: -------------------------------------------------------------------------------- 1 | package assume 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/common-fate/clio" 7 | "github.com/common-fate/granted/internal/build" 8 | "github.com/common-fate/granted/pkg/alias" 9 | "github.com/common-fate/granted/pkg/autosync" 10 | "github.com/common-fate/granted/pkg/banners" 11 | "github.com/common-fate/granted/pkg/browser" 12 | "github.com/common-fate/granted/pkg/config" 13 | "github.com/common-fate/useragent" 14 | "github.com/urfave/cli/v2" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | // Prevent issues where these flags are initialised in some part of the program then used by another part 19 | // For our use case, we need fresh copies of these flags in the app and in the assume command 20 | // we use this to allow flags to be set on either side of the profile arg e.g `assume -c profile-name -r ap-southeast-2` 21 | func GlobalFlags() []cli.Flag { 22 | return []cli.Flag{ 23 | &cli.BoolFlag{Name: "console", Aliases: []string{"c"}, Usage: "Open a web console to the role"}, 24 | &cli.BoolFlag{Name: "terminal", Aliases: []string{"t"}, Usage: "Use this with '-c' to open a console session and export credentials into the terminal at the same time."}, 25 | &cli.BoolFlag{Name: "env", Aliases: []string{"e"}, Usage: "Export credentials to a .env file"}, 26 | &cli.BoolFlag{Name: "export", Aliases: []string{"ex"}, Usage: "Export credentials to a ~/.aws/credentials file"}, 27 | &cli.BoolFlag{Name: "unset", Aliases: []string{"un"}, Usage: "Unset all environment variables configured by Assume"}, 28 | &cli.BoolFlag{Name: "url", Aliases: []string{"u"}, Usage: "Get an active console session url"}, 29 | &cli.StringFlag{Name: "service", Aliases: []string{"s"}, Usage: "Like --c, but opens to a specified service"}, 30 | &cli.StringFlag{Name: "region", Aliases: []string{"r"}, Usage: "region to launch the console or export to the terminal"}, 31 | &cli.StringFlag{Name: "console-destination", Aliases: []string{"cd"}, Usage: "Open a web console at this destination"}, 32 | &cli.StringSliceFlag{Name: "pass-through", Aliases: []string{"pt"}, Usage: "Pass args to proxy assumer"}, 33 | &cli.BoolFlag{Name: "active-role", Aliases: []string{"ar"}, Usage: "Open console using active role"}, 34 | &cli.BoolFlag{Name: "verbose", Usage: "Log debug messages"}, 35 | &cli.StringFlag{Name: "update-checker-api-url", Value: build.UpdateCheckerApiUrl, EnvVars: []string{"UPDATE_CHECKER_API_URL"}, Hidden: true}, 36 | &cli.StringFlag{Name: "active-aws-profile", EnvVars: []string{"AWS_PROFILE"}, Hidden: true}, 37 | &cli.BoolFlag{Name: "auto-configure-shell", Usage: "Configure shell alias without prompts"}, 38 | &cli.StringFlag{Name: "exec", Usage: "Assume a profile then execute this command"}, 39 | &cli.StringFlag{Name: "duration", Aliases: []string{"d"}, Usage: "Set session duration for your assumed role"}, 40 | &cli.BoolFlag{Name: "sso", Usage: "Assume an account and role with provided SSO flags"}, 41 | &cli.StringFlag{Name: "sso-start-url", Usage: "Use this in conjunction with --sso, the sso-start-url"}, 42 | &cli.StringFlag{Name: "sso-region", Usage: "Use this in conjunction with --sso, the sso-region"}, 43 | &cli.StringFlag{Name: "account-id", Usage: "Use this in conjunction with --sso, the account-id"}, 44 | &cli.StringFlag{Name: "role-name", Usage: "Use this in conjunction with --sso, the role-name"}, 45 | &cli.StringFlag{Name: "save-to", Usage: "Use this in conjunction with --sso, the profile name to save the role to in your AWS config file"}, 46 | } 47 | } 48 | 49 | func GetCliApp() *cli.App { 50 | cli.VersionPrinter = func(c *cli.Context) { 51 | clio.Log(banners.WithVersion(banners.Assume())) 52 | } 53 | 54 | app := &cli.App{ 55 | Name: "assume", 56 | Writer: os.Stderr, 57 | Usage: "https://granted.dev", 58 | UsageText: "assume [options][Profile]", 59 | Version: build.Version, 60 | HideVersion: false, 61 | Flags: GlobalFlags(), 62 | Action: AssumeCommand, 63 | EnableBashCompletion: true, 64 | BashComplete: Completion, 65 | Before: func(c *cli.Context) error { 66 | 67 | // unsets the exported env vars 68 | if c.Bool("unset") { 69 | err := UnsetAction(c) 70 | if err != nil { 71 | return err 72 | } 73 | os.Exit(0) 74 | } 75 | 76 | clio.SetLevelFromEnv("GRANTED_LOG") 77 | zap.ReplaceGlobals(clio.G()) 78 | if c.Bool("verbose") { 79 | clio.SetLevelFromString("debug") 80 | } 81 | err := ValidateSSOFlags(c) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | if err := config.SetupConfigFolder(); err != nil { 87 | return err 88 | } 89 | 90 | hasSetup, err := browser.UserHasDefaultBrowser(c) 91 | if err != nil { 92 | return err 93 | } 94 | if !hasSetup { 95 | browserName, err := browser.HandleBrowserWizard(c) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | // see if they want to set their sso browser the same as their granted default 101 | err = browser.SSOBrowser(browserName) 102 | if err != nil { 103 | return err 104 | } 105 | 106 | // run instructions 107 | // terminates the command with os.exit(0) 108 | browser.GrantedIntroduction() 109 | } 110 | // Sync granted profile registries if enabled 111 | autosync.Run(false) 112 | 113 | // Setup the shell alias 114 | if os.Getenv("FORCE_NO_ALIAS") != "true" { 115 | return alias.MustBeConfigured(c.Bool("auto-configure-shell")) 116 | } 117 | 118 | // set the user agent 119 | c.Context = useragent.NewContext(c.Context, "granted", build.Version) 120 | 121 | return nil 122 | }, 123 | } 124 | 125 | app.EnableBashCompletion = true 126 | 127 | return app 128 | } 129 | -------------------------------------------------------------------------------- /pkg/cfaws/ssotoken.go: -------------------------------------------------------------------------------- 1 | package cfaws 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/hex" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "github.com/aws/aws-sdk-go-v2/config" 15 | "github.com/common-fate/granted/pkg/securestorage" 16 | ) 17 | 18 | const ( 19 | // permission for user to read/write/execute. 20 | USER_READ_WRITE_PERM = 0700 21 | ) 22 | 23 | type SSOPlainTextOut struct { 24 | AccessToken string `json:"accessToken"` 25 | ExpiresAt string `json:"expiresAt"` 26 | StartUrl string `json:"startUrl"` 27 | Region string `json:"region"` 28 | } 29 | 30 | // CreatePlainTextSSO is currently unused. In a future version of the Granted CLI, 31 | // we'll allow users to export a plaintext token from their keychain for compatibility 32 | // purposes with other AWS tools. 33 | // 34 | // see: https://github.com/common-fate/granted/issues/155 35 | func CreatePlainTextSSO(awsConfig config.SharedConfig, token *securestorage.SSOToken) *SSOPlainTextOut { 36 | return &SSOPlainTextOut{ 37 | AccessToken: token.AccessToken, 38 | ExpiresAt: token.Expiry.Format(time.RFC3339), 39 | Region: awsConfig.Region, 40 | StartUrl: awsConfig.SSOStartURL, 41 | } 42 | } 43 | 44 | func (s *SSOPlainTextOut) DumpToCacheDirectory() error { 45 | jsonOut, err := json.Marshal(s) 46 | if err != nil { 47 | return fmt.Errorf("unable to parse token to json with err %s", err) 48 | } 49 | 50 | err = dumpTokenFile(jsonOut, s.StartUrl) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func getCacheFileName(url string) (string, error) { 59 | hash := sha1.New() 60 | _, err := hash.Write([]byte(url)) 61 | if err != nil { 62 | return "", err 63 | } 64 | return strings.ToLower(hex.EncodeToString(hash.Sum(nil))) + ".json", nil 65 | } 66 | 67 | // Write SSO token as JSON output to default cache location. 68 | func dumpTokenFile(jsonToken []byte, url string) error { 69 | key, err := getCacheFileName(url) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | path, err := getDefaultCacheLocation() 75 | if err != nil { 76 | return err 77 | } 78 | 79 | if _, err := os.Stat(path); os.IsNotExist(err) { 80 | err := os.MkdirAll(path, USER_READ_WRITE_PERM) 81 | if err != nil { 82 | return fmt.Errorf("unable to create sso cache directory with err: %s", err) 83 | } 84 | } 85 | 86 | err = os.WriteFile(filepath.Join(path, key), jsonToken, USER_READ_WRITE_PERM) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | return nil 92 | } 93 | 94 | // Find the ~/.aws/sso/cache absolute path based on OS. 95 | func getDefaultCacheLocation() (string, error) { 96 | h, err := os.UserHomeDir() 97 | if err != nil { 98 | return "", err 99 | } 100 | 101 | cachePath := filepath.Join(h, ".aws", "sso", "cache") 102 | return cachePath, nil 103 | } 104 | 105 | // check if a valid ~/.aws/sso/cache file exists 106 | func SsoCredsAreInConfigCache() bool { 107 | path, err := getDefaultCacheLocation() 108 | if err != nil { 109 | return false 110 | } 111 | // now open the folder 112 | f, err := os.Open(path) 113 | if err != nil { 114 | return false 115 | } 116 | 117 | // close the folder 118 | defer f.Close() 119 | return true 120 | } 121 | 122 | func ReadPlaintextSsoCreds(startUrl string) (SSOPlainTextOut, error) { 123 | 124 | /** 125 | 126 | The path will like this so we'll want to open the folder then scan over json files. 127 | 128 | ~/.aws/sso/cache 129 | +└── a092ca4eExample27b5add8ec31d9b.json 130 | +└── a092ca4eExample27b5add8ec31d9b.json 131 | 132 | */ 133 | 134 | path, err := getDefaultCacheLocation() 135 | if err != nil { 136 | return SSOPlainTextOut{}, err 137 | } 138 | // now open the folder 139 | f, err := os.Open(path) 140 | if err != nil { 141 | return SSOPlainTextOut{}, err 142 | } 143 | // now read the folder 144 | files, err := f.Readdir(-1) 145 | if err != nil { 146 | return SSOPlainTextOut{}, err 147 | } 148 | // close the folder 149 | defer f.Close() 150 | for _, file := range files { 151 | // check if the file is a json file 152 | if filepath.Ext(file.Name()) == ".json" { 153 | // open the file 154 | f, err := os.Open(filepath.Join(path, file.Name())) 155 | if err != nil { 156 | return SSOPlainTextOut{}, err 157 | } 158 | // read the file 159 | data, err := io.ReadAll(f) 160 | if err != nil { 161 | return SSOPlainTextOut{}, err 162 | } 163 | 164 | // if file doesn't start with botocore 165 | if !strings.HasPrefix(file.Name(), "botocore") { 166 | // close the file 167 | defer f.Close() 168 | // unmarshal the json 169 | var sso SSOPlainTextOut 170 | err = json.Unmarshal(data, &sso) 171 | if err != nil { 172 | return SSOPlainTextOut{}, err 173 | } 174 | // check if the startUrl matches 175 | if sso.StartUrl == startUrl { 176 | return sso, nil 177 | } 178 | } 179 | } 180 | } 181 | return SSOPlainTextOut{}, fmt.Errorf("no valid sso token found") 182 | } 183 | 184 | func GetValidSSOTokenFromPlaintextCache(startUrl string) *securestorage.SSOToken { 185 | if SsoCredsAreInConfigCache() { 186 | creds, err := ReadPlaintextSsoCreds(startUrl) 187 | if err != nil { 188 | return nil 189 | } 190 | var ssoPlaintextOutput securestorage.SSOToken 191 | ssoPlaintextOutput.AccessToken = creds.AccessToken 192 | 193 | // from iso string to time.Time 194 | ssoPlaintextOutput.Expiry, err = time.Parse(time.RFC3339, creds.ExpiresAt) 195 | if err != nil { 196 | return nil 197 | } 198 | // if it's expired return nil 199 | if ssoPlaintextOutput.Expiry.Before(time.Now()) { 200 | return nil 201 | } 202 | 203 | return &ssoPlaintextOutput 204 | } 205 | return nil 206 | } 207 | --------------------------------------------------------------------------------