├── VERSION.md ├── doc ├── kion-cli-hero.png ├── kion-cli-usage.gif ├── config_sop.md ├── release_sop.md └── man1 │ └── kion.1 ├── lib ├── helper │ ├── helper.go │ ├── prompts.go │ ├── configuration.go │ ├── shell.go │ ├── output_test.go │ ├── output.go │ ├── wizards.go │ ├── helper_test.go │ ├── transform_test.go │ ├── transform.go │ └── browser.go ├── structs │ ├── structs.go │ ├── session-structs.go │ ├── helper-structs.go │ └── configuration-structs.go ├── defaults │ ├── defaults.go │ └── defaults.yml ├── kion │ ├── private.go │ ├── console.go │ ├── stak.go │ ├── project.go │ ├── account.go │ ├── auth.go │ ├── favorite.go │ ├── kion.go │ └── car.go ├── cache │ ├── helpers-cache.go │ ├── cache.go │ ├── password-cache.go │ ├── session-cache.go │ └── stak-cache.go └── commands │ ├── console.go │ ├── run.go │ ├── stak.go │ ├── favorite.go │ ├── authentication.go │ ├── utility.go │ └── commands.go ├── _kion ├── LICENSE ├── go.mod ├── go.sum ├── main.go └── CHANGELOG.md /VERSION.md: -------------------------------------------------------------------------------- 1 | v0.14.0 2 | -------------------------------------------------------------------------------- /doc/kion-cli-hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kionsoftware/kion-cli/HEAD/doc/kion-cli-hero.png -------------------------------------------------------------------------------- /doc/kion-cli-usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kionsoftware/kion-cli/HEAD/doc/kion-cli-usage.gif -------------------------------------------------------------------------------- /lib/helper/helper.go: -------------------------------------------------------------------------------- 1 | // Package helper provides utility functions for various common tasks. 2 | package helper 3 | -------------------------------------------------------------------------------- /lib/structs/structs.go: -------------------------------------------------------------------------------- 1 | // Package structs provides the data structures used across the application. 2 | package structs 3 | -------------------------------------------------------------------------------- /_kion: -------------------------------------------------------------------------------- 1 | #compdef kion 2 | 3 | _cli_zsh_autocomplete() { 4 | local -a opts 5 | local cur 6 | cur=${words[-1]} 7 | if [[ "$cur" == "-"* ]]; then 8 | opts=("${(@f)$(${words[@]:0:#words[@]-1} ${cur} --generate-bash-completion)}") 9 | else 10 | opts=("${(@f)$(${words[@]:0:#words[@]-1} --generate-bash-completion)}") 11 | fi 12 | 13 | if [[ "${opts[1]}" != "" ]]; then 14 | _describe 'values' opts 15 | else 16 | _files 17 | fi 18 | } 19 | 20 | compdef _cli_zsh_autocomplete kion 21 | -------------------------------------------------------------------------------- /lib/defaults/defaults.go: -------------------------------------------------------------------------------- 1 | // Package defaults provides access to the default configuration file embedded 2 | // in the binary. This file is used to initialize the configuration file for 3 | // organizations that want to distribute a binary with custom defaults. 4 | package defaults 5 | 6 | import ( 7 | "embed" 8 | ) 9 | 10 | //go:embed defaults.yml 11 | var ConfigFS embed.FS 12 | 13 | // GetDefaultConfig returns the default configuration. 14 | func GetDefaultConfig() ([]byte, error) { 15 | return ConfigFS.ReadFile("defaults.yml") 16 | } 17 | -------------------------------------------------------------------------------- /lib/structs/session-structs.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | //////////////////////////////////////////////////////////////////////////////// 4 | // // 5 | // Structs // 6 | // // 7 | //////////////////////////////////////////////////////////////////////////////// 8 | 9 | // SessionInfo holds the information about the federated session. 10 | type SessionInfo struct { 11 | AccountName string 12 | AccountNumber string 13 | AccountTypeID uint 14 | AwsIamRoleName string 15 | Region string 16 | } 17 | -------------------------------------------------------------------------------- /lib/structs/helper-structs.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | // FavoritesComparison holds the results of comparing local favorites with API 4 | // favorites. It includes all favorites, exact matches, non-matches, conflicts, 5 | // and local-only favorites. It's returned by the CombineFavorites function. 6 | type FavoritesComparison struct { 7 | All []Favorite // Combined local + API, deduplicated and deconflicted 8 | ConflictsLocal []Favorite // Name conflicts (same name, different settings) 9 | ConflictsUpstream []Favorite // Name conflicts (same name, different settings) 10 | LocalOnly []Favorite // Local-only favorites (not matched in API) 11 | UnaliasedLocal []Favorite // Local favorites that update unnamed API favorites 12 | UnaliasedUpstream []Favorite // Local favorites that update unnamed API favorites 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nor Labs, Inc., DBA Kion 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 | -------------------------------------------------------------------------------- /doc/config_sop.md: -------------------------------------------------------------------------------- 1 | Config SOP 2 | ========== 3 | 4 | When adding new configuration options there are a few things to keep in mind in 5 | order to support several config features and established operational behavior. 6 | This SOP ensures the appropriate precedence is followed and users can 7 | intuitively use new options in ways they have established with existing ones. 8 | This SOP assumes you are adding an option to the configuration file that will 9 | also be a global flag / option. 10 | 11 | 1. Add the option to the configuration struct `lib/structs/configuraton-structs.go` 12 | 2. Add the option to the defaults override file `lib/defaults/defaults.yml` only if it is non-sensitive in nature 13 | 3. Set the option as a flag in `main.go`, this is what handles precedence as documented in the repo `README.md` file 14 | 4. If a non string var add a manual re-setting of it for when profiles are switched in `lib/commands/commands.go` 15 | 5. Test to ensure precedence is being followed as expected: 16 | 1. No `defaults.yml` entry, no config file 17 | 2. Then add an override in `defaults.yml` 18 | 3. Then add an override entry in your `~/.kion.yml` 19 | 4. Then add an override in the environment variable 20 | 5. Then add an override in the flag option 21 | -------------------------------------------------------------------------------- /lib/kion/private.go: -------------------------------------------------------------------------------- 1 | package kion 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // ConsoleAccessCAR maps to the Kion API response for CAR data. 9 | type ConsoleAccessCAR struct { 10 | CARName string `json:"name"` 11 | CARID uint `json:"id"` 12 | CARRoleType string `json:"role_type"` 13 | Accounts []Account `json:"accounts"` 14 | ConsoleAccess bool `json:"console_access"` 15 | STAKAccess bool `json:"short_term_key_access"` 16 | LTAKAccess bool `json:"long_term_key_access"` 17 | AwsIamRoleName string `json:"aws_iam_role_name"` 18 | } 19 | 20 | // GetConsoleAccessCARS hits the private API endpoint to gather all cloud 21 | // access roles a user has access to. This method should only be used as a 22 | // fallback. 23 | func GetConsoleAccessCARS(host string, token string, projID uint) ([]ConsoleAccessCAR, error) { 24 | // build our query and get response 25 | url := fmt.Sprintf("%v/api/v1/project/%v/console-access", host, projID) 26 | query := map[string]string{} 27 | var data any 28 | resp, _, err := runQuery("GET", url, token, query, data) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | // unmarshal response body 34 | var consoleAccessCars []ConsoleAccessCAR 35 | err = json.Unmarshal(resp.Data, &consoleAccessCars) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | return consoleAccessCars, nil 41 | } 42 | -------------------------------------------------------------------------------- /doc/release_sop.md: -------------------------------------------------------------------------------- 1 | Release SOP 2 | =========== 3 | 4 | 1. Bump `VERSION.md` file according to [semver](https://semver.org/) practices 5 | 2. Create section in `CHANGELOG.md` that matches the new version 6 | - Write release paragraph at the top of the new release section in changelog 7 | - Ensure all non-backend, non-administrative PR's are accounted for in subsections (changed, added, etc) 8 | - Stub out new placeholder for next release at top of changelog 9 | 3. Verify all documentation is in place for the new features: 10 | - Repo `README.md` file 11 | - The `kion.1` and/or other man page(s) 12 | 3. Push and merge into main 13 | 4. Checkout main locally, pull from upstream 14 | 5. Tag with the release version and push 15 | ```bash 16 | git tag -a v0.5.0 -m "Release v0.5.0" 17 | git push origin v0.5.0 18 | ``` 19 | 6. Monitor release pipeline and ensure it passes, along with the brew repo pipeline 20 | 7. If there is an issue with a pipeline 21 | ```bash 22 | # pull tag, fix an issue, re-tag (tag v0.5.0 for example) 23 | 24 | git push origin :refs/tags/v0.5.0 # removes the tag upstream 25 | 26 | # do git changes and git push origin [branch] then pr back into master 27 | 28 | git tag -fa v0.5.0 -m "Release v0.5.0" # force a move / retag 29 | git push origin v0.5.0 30 | ``` 31 | 8. Validate the release 32 | - Homebrew repo shows new commit and the formula has been updated 33 | - Cli repo shows new release and release notes look correct 34 | - Upgrade locally via brew `brew update && brew upgrade kionsoftware/tap/kion-cli && kion --version` 35 | -------------------------------------------------------------------------------- /lib/helper/prompts.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import "github.com/AlecAivazis/survey/v2" 4 | 5 | //////////////////////////////////////////////////////////////////////////////// 6 | // // 7 | // Prompts // 8 | // // 9 | //////////////////////////////////////////////////////////////////////////////// 10 | 11 | // surveyFormat sets survey icon and color configs. 12 | var surveyFormat = survey.WithIcons(func(icons *survey.IconSet) { 13 | icons.Question.Text = "" 14 | icons.Question.Format = "default+hb" 15 | }) 16 | 17 | // PromptSelect prompts the user to select from a slice of options. It requires 18 | // that the selection made be one of the options provided. 19 | func PromptSelect(message string, options []string) (string, error) { 20 | selection := "" 21 | prompt := &survey.Select{ 22 | Message: message, 23 | Options: options, 24 | } 25 | err := survey.AskOne(prompt, &selection, surveyFormat) 26 | return selection, err 27 | } 28 | 29 | // PromptInput prompts the user to provide dynamic input. 30 | func PromptInput(message string) (string, error) { 31 | var input string 32 | pi := &survey.Input{ 33 | Message: message, 34 | } 35 | err := survey.AskOne(pi, &input, surveyFormat, survey.WithValidator(survey.Required)) 36 | return input, err 37 | } 38 | 39 | // PromptPassword prompts the user to provide sensitive dynamic input. 40 | func PromptPassword(message string) (string, error) { 41 | var input string 42 | pi := &survey.Password{ 43 | Message: message, 44 | } 45 | err := survey.AskOne(pi, &input, surveyFormat, survey.WithValidator(survey.Required)) 46 | return input, err 47 | } 48 | -------------------------------------------------------------------------------- /lib/cache/helpers-cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/99designs/keyring" 7 | ) 8 | 9 | // flushCache clears the Kion CLI cache. 10 | func flushCache(k keyring.Keyring) error { 11 | // marshal an empty cache to json 12 | var cacheData CacheData 13 | data, err := json.Marshal(cacheData) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | // build the keyring item 19 | cacheName := "Kion-CLI Cache" 20 | cache := keyring.Item{ 21 | Key: cacheName, 22 | Data: data, 23 | Label: cacheName, 24 | Description: "Cache data for the Kion-CLI.", 25 | } 26 | 27 | // store the cache 28 | err = k.Set(cache) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | return nil 34 | } 35 | 36 | //////////////////////////////////////////////////////////////////////////////// 37 | // // 38 | // Real Cacher // 39 | // // 40 | //////////////////////////////////////////////////////////////////////////////// 41 | 42 | // FLushCache implements the FlushCache interface for RealCache. 43 | func (c *RealCache) FlushCache() error { 44 | return flushCache(c.keyring) 45 | } 46 | 47 | //////////////////////////////////////////////////////////////////////////////// 48 | // // 49 | // Null Cacher // 50 | // // 51 | //////////////////////////////////////////////////////////////////////////////// 52 | 53 | // FLushCache implements the FlushCache interface for NullCache. 54 | // This is just to satisfy the interface and should never be called. 55 | func (c *NullCache) FlushCache() error { 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /lib/kion/console.go: -------------------------------------------------------------------------------- 1 | package kion 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | //////////////////////////////////////////////////////////////////////////////// 9 | // // 10 | // Console // 11 | // // 12 | //////////////////////////////////////////////////////////////////////////////// 13 | 14 | // URLRequest maps to the required post body when interfacing with the Kion 15 | // API. 16 | type URLRequest struct { 17 | AccountID uint `json:"account_id"` 18 | AccountName string `json:"account_name"` 19 | AccountNumber string `json:"account_number"` 20 | AWSIAMRoleName string `json:"aws_iam_role_name"` 21 | AccountTypeID uint `json:"account_type_id"` 22 | RoleID uint `json:"role_id"` 23 | RoleType string `json:"role_type"` 24 | } 25 | 26 | // GetFederationURL queries the Kion API to generate a federation URL. 27 | func GetFederationURL(host string, token string, car CAR) (string, error) { 28 | // converting cloud access role type to role type 29 | var roleType string 30 | switch car.CloudAccessRoleType { 31 | case "inherited": 32 | roleType = "ou" 33 | case "local": 34 | roleType = "project" 35 | } 36 | 37 | // build our query and get response 38 | url := fmt.Sprintf("%v/api/v1/console-access", host) 39 | query := map[string]string{} 40 | data := URLRequest{ 41 | AccountID: car.AccountID, 42 | AccountName: car.AccountName, 43 | AccountNumber: car.AccountNumber, 44 | AWSIAMRoleName: car.AwsIamRoleName, 45 | AccountTypeID: car.AccountTypeID, 46 | RoleID: car.ID, 47 | RoleType: roleType, 48 | } 49 | resp, _, err := runQuery("POST", url, token, query, data) 50 | if err != nil { 51 | return "", err 52 | } 53 | 54 | var fedurl string 55 | err = json.Unmarshal(resp.Data, &fedurl) 56 | if err != nil { 57 | return "", err 58 | } 59 | 60 | return fedurl, nil 61 | } 62 | -------------------------------------------------------------------------------- /lib/helper/configuration.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/kionsoftware/kion-cli/lib/defaults" 9 | "github.com/kionsoftware/kion-cli/lib/structs" 10 | 11 | "gopkg.in/yaml.v2" 12 | ) 13 | 14 | //////////////////////////////////////////////////////////////////////////////// 15 | // // 16 | // Configuration // 17 | // // 18 | //////////////////////////////////////////////////////////////////////////////// 19 | 20 | // LoadConfig reads in the embedded configuration file as well as the users 21 | // configuration yaml file located at `configFile`. Precedence is given to the 22 | // users configuration file, so any values set there will override the embedded 23 | // defaults. 24 | func LoadConfig(filename string, config *structs.Configuration) error { 25 | // read embedded defaults into the config 26 | defaultConfig, err := defaults.GetDefaultConfig() 27 | if err == nil { 28 | // only try to parse if we successfully got the embedded config 29 | if err := yaml.Unmarshal(defaultConfig, &config); err != nil { 30 | return fmt.Errorf("failed to parse embedded configuration: %w", err) 31 | } 32 | } 33 | 34 | // try to read users config file 35 | data, err := os.ReadFile(filename) 36 | if err != nil { 37 | // if file doesn't exist, just use embedded defaults 38 | if errors.Is(err, os.ErrNotExist) { 39 | return nil 40 | } 41 | return err 42 | } 43 | 44 | // parse external config and override defaults 45 | if err := yaml.Unmarshal(data, config); err != nil { 46 | return fmt.Errorf("failed to parse config file %s: %w", filename, err) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | // SaveConfig saves the entirety of the current config to the users config file. 53 | func SaveConfig(filename string, config structs.Configuration) error { 54 | // marshal to yaml 55 | bytes, err := yaml.Marshal(config) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | // write it out 61 | return os.WriteFile(filename, bytes, 0644) 62 | } 63 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kionsoftware/kion-cli 2 | 3 | go 1.24.4 4 | 5 | require ( 6 | github.com/99designs/keyring v1.2.2 7 | github.com/AlecAivazis/survey/v2 v2.3.6 8 | github.com/fatih/color v1.15.0 9 | github.com/hashicorp/go-version v1.6.0 10 | github.com/russellhaering/gosaml2 v0.9.1 11 | github.com/russellhaering/goxmldsig v1.4.0 12 | github.com/urfave/cli/v2 v2.25.1 13 | gopkg.in/yaml.v2 v2.4.0 14 | ) 15 | 16 | require ( 17 | github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 19 | github.com/beevik/etree v1.1.0 // indirect 20 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 21 | github.com/charmbracelet/lipgloss v1.1.0 // indirect 22 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 23 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 24 | github.com/charmbracelet/x/term v0.2.1 // indirect 25 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 26 | github.com/danieljoos/wincred v1.1.2 // indirect 27 | github.com/dvsekhvalnov/jose2go v1.6.0 // indirect 28 | github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect 29 | github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect 30 | github.com/jonboulle/clockwork v0.3.0 // indirect 31 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 32 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 33 | github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect 34 | github.com/mattn/go-colorable v0.1.13 // indirect 35 | github.com/mattn/go-isatty v0.0.20 // indirect 36 | github.com/mattn/go-runewidth v0.0.16 // indirect 37 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 38 | github.com/mtibben/percent v0.2.1 // indirect 39 | github.com/muesli/termenv v0.16.0 // indirect 40 | github.com/rivo/uniseg v0.4.7 // indirect 41 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 42 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 43 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 44 | golang.org/x/sys v0.30.0 // indirect 45 | golang.org/x/term v0.7.0 // indirect 46 | golang.org/x/text v0.9.0 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /lib/defaults/defaults.yml: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | ## ## 3 | ## Attention!! ## 4 | ## ## 5 | ## vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv ## 6 | ## ## 7 | ## DO NOT STORE SENSITIVE CONFIGURATIONS IN THIS FILE. All contents will be ## 8 | ## stored as plain text inside the compiled Kion CLI binary & be accessible ## 9 | ## to any user with read access. ## 10 | ## ## 11 | ## ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ## 12 | ## ## 13 | ## Values stored here are overrides to the Kion CLI defaults (the config ## 14 | ## structs null values). This is provided as a convenience to create custom ## 15 | ## builds of the CLI for distribution within companies. Precedence will be ## 16 | ## as follows: ## 17 | ## ## 18 | ## Flag > Environment Variables > Config File > Default Values ## 19 | ## `------------' ## 20 | ## | ## 21 | ## .-------------------------------------------' ## 22 | ## V ## 23 | ## Default Values (THIS DEFAULTS.YML > config struct null values) ## 24 | ## ## 25 | ################################################################################ 26 | 27 | kion: 28 | url: "" 29 | idms_id: "" 30 | saml_metadata_file: "" 31 | saml_sp_issuer: "" 32 | saml_print_url: false 33 | disable_cache: false 34 | debug_mode: false 35 | quiet_mode: false 36 | -------------------------------------------------------------------------------- /lib/cache/cache.go: -------------------------------------------------------------------------------- 1 | // Package cache provides caching functionality for kion-cli using a keyring. 2 | package cache 3 | 4 | import ( 5 | "github.com/99designs/keyring" 6 | "github.com/kionsoftware/kion-cli/lib/kion" 7 | ) 8 | 9 | // Cache is an interface for storing and receiving data. 10 | type Cache interface { 11 | SetStak(carName string, accNum string, accAlias string, value kion.STAK) error 12 | GetStak(carName string, accNum string, accAlias string) (kion.STAK, bool, error) 13 | SetSession(value kion.Session) error 14 | GetSession() (kion.Session, bool, error) 15 | SetPassword(host string, idmsID uint, un string, pw string) error 16 | GetPassword(host string, idmsID uint, un string) (string, bool, error) 17 | FlushCache() error 18 | } 19 | 20 | //////////////////////////////////////////////////////////////////////////////// 21 | // // 22 | // Real Cacher // 23 | // // 24 | //////////////////////////////////////////////////////////////////////////////// 25 | 26 | // RealCache is our cache object for passing the keychain to receiver methods. 27 | type RealCache struct { 28 | keyring keyring.Keyring 29 | } 30 | 31 | // CacheData is a nested structure for storing kion-cli data. 32 | type CacheData struct { 33 | STAK map[string]kion.STAK 34 | SESSION kion.Session 35 | PASSWORD map[string]string 36 | } 37 | 38 | // NewCache creates a new RealCache. 39 | func NewCache(keyring keyring.Keyring) *RealCache { 40 | return &RealCache{ 41 | keyring: keyring, 42 | } 43 | } 44 | 45 | //////////////////////////////////////////////////////////////////////////////// 46 | // // 47 | // Null Cacher // 48 | // // 49 | //////////////////////////////////////////////////////////////////////////////// 50 | 51 | // NullCache implements the Cache interface and does nothing. 52 | type NullCache struct { 53 | } 54 | 55 | // NewNullCache creates a new NullCache. 56 | func NewNullCache() *NullCache { 57 | return &NullCache{} 58 | } 59 | -------------------------------------------------------------------------------- /lib/kion/stak.go: -------------------------------------------------------------------------------- 1 | package kion 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | //////////////////////////////////////////////////////////////////////////////// 10 | // // 11 | // Short Term Access Keys // 12 | // // 13 | //////////////////////////////////////////////////////////////////////////////// 14 | 15 | // STAK maps to the Kion API response for short term access keys. 16 | type STAK struct { 17 | AccessKey string `json:"access_key"` 18 | SecretAccessKey string `json:"secret_access_key"` 19 | SessionToken string `json:"session_token"` 20 | Duration int64 `json:"duration"` 21 | Expiration time.Time 22 | } 23 | 24 | // STAKRequest maps to the required post body when interfacing with the Kion 25 | // API. 26 | type STAKRequest struct { 27 | AccountNumber string `json:"account_number"` 28 | AccountAlias string `json:"account_alias"` 29 | CARName string `json:"cloud_access_role_name"` 30 | } 31 | 32 | // GetSTAK queries the Kion API to generate short term access keys. Must pass 33 | // either an account number or an account alias, one can be "". 34 | func GetSTAK(host string, token string, carName string, accNum string, accAlias string) (STAK, error) { 35 | // only account number or account alias should be provided, use the account 36 | // number by default 37 | if accNum != "" && accAlias != "" { 38 | accAlias = "" 39 | } 40 | 41 | // build our query and get response 42 | url := fmt.Sprintf("%v/api/v3/temporary-credentials/cloud-access-role", host) 43 | query := map[string]string{} 44 | data := STAKRequest{ 45 | AccountNumber: accNum, 46 | AccountAlias: accAlias, 47 | CARName: carName, 48 | } 49 | resp, _, err := runQuery("POST", url, token, query, data) 50 | if err != nil { 51 | return STAK{}, err 52 | } 53 | 54 | // unmarshal response body 55 | var stak STAK 56 | err = json.Unmarshal(resp.Data, &stak) 57 | if err != nil { 58 | return STAK{}, err 59 | } 60 | 61 | // set the expiration time, buffer by 30 seconds 62 | duration := stak.Duration 63 | if duration == 0 { 64 | duration = 900 65 | } 66 | stak.Expiration = time.Now().Add(time.Duration(duration-30) * time.Second) 67 | 68 | return stak, nil 69 | } 70 | -------------------------------------------------------------------------------- /lib/commands/console.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/kionsoftware/kion-cli/lib/helper" 8 | "github.com/kionsoftware/kion-cli/lib/kion" 9 | "github.com/kionsoftware/kion-cli/lib/structs" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | // FedConsole opens the CSP console for the selected account and cloud access 14 | // role in the user's default browser. 15 | func (c *Cmd) FedConsole(cCtx *cli.Context) error { 16 | // handle auth 17 | err := c.setAuthToken(cCtx) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | // retrieve the account number, account alias, and CAR name from the context 23 | accNum := cCtx.String("account") 24 | accountAlias := cCtx.String("alias") 25 | carName := cCtx.String("car") 26 | 27 | var car kion.CAR 28 | if carName != "" && (accNum != "" || accountAlias != "") { 29 | // fetch the car directly using account number or alias and car name 30 | if accNum != "" { 31 | car, err = kion.GetCARByNameAndAccount(c.config.Kion.URL, c.config.Kion.APIKey, carName, accNum) 32 | if err != nil { 33 | return fmt.Errorf("failed to get CAR for account %s and CAR %s: %v", accNum, carName, err) 34 | } 35 | } else { 36 | car, err = kion.GetCARByNameAndAlias(c.config.Kion.URL, c.config.Kion.APIKey, carName, accountAlias) 37 | if err != nil { 38 | return fmt.Errorf("failed to get CAR for alias %s and CAR %s: %v", accountAlias, carName, err) 39 | } 40 | } 41 | } else { 42 | // walk user through the prompt workflow to select a car 43 | err = helper.CARSelector(cCtx, &car) 44 | if err != nil { 45 | return err 46 | } 47 | } 48 | 49 | // grab the csp federation url 50 | url, err := kion.GetFederationURL(c.config.Kion.URL, c.config.Kion.APIKey, car) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | // grab the second argument, used as a redirect parameter 56 | redirect := getSecondArgument(cCtx) 57 | 58 | // print out how to store as a favorite 59 | if !c.config.Kion.QuietMode { 60 | if err := helper.PrintFavoriteConfig(os.Stdout, car, "", "web"); err != nil { 61 | return err 62 | } 63 | } 64 | 65 | session := structs.SessionInfo{ 66 | AccountName: car.AccountName, 67 | AccountNumber: car.AccountNumber, 68 | AccountTypeID: car.AccountTypeID, 69 | AwsIamRoleName: car.AwsIamRoleName, 70 | } 71 | return helper.OpenBrowserRedirect(url, session, c.config.Browser, redirect, "") 72 | } 73 | -------------------------------------------------------------------------------- /lib/kion/project.go: -------------------------------------------------------------------------------- 1 | package kion 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | //////////////////////////////////////////////////////////////////////////////// 9 | // // 10 | // Projects // 11 | // // 12 | //////////////////////////////////////////////////////////////////////////////// 13 | 14 | // Project maps to the Kion API response for projects. 15 | type Project struct { 16 | Archived bool `json:"archived"` 17 | AutoPay bool `json:"auto_pay"` 18 | DefaultAwsRegion string `json:"default_aws_region"` 19 | Description string `json:"description"` 20 | ID uint `json:"id"` 21 | Name string `json:"name"` 22 | OuID uint `json:"ou_id"` 23 | } 24 | 25 | // GetProject queries the Kion API for a list of all projects within the application. 26 | func GetProjects(host string, token string) ([]Project, error) { 27 | // build our query and get response 28 | url := host + "/api/v3/project" 29 | query := map[string]string{} 30 | var data any 31 | resp, _, err := runQuery("GET", url, token, query, data) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | // unmarshal response body 37 | var projects []Project 38 | err = json.Unmarshal(resp.Data, &projects) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return projects, nil 44 | } 45 | 46 | // GetProjectByID returns the project for a given project ID. Note that if a 47 | // user has car access only to a project this will return a 403. To accommodate 48 | // users with minimal permissions test response codes and fallback accordingly 49 | // or use GetProjects which will work but be more verbose. 50 | func GetProjectByID(host string, token string, id uint) (Project, error) { 51 | // build our query and get response 52 | url := fmt.Sprintf("%v/api/v3/project/%v", host, id) 53 | query := map[string]string{} 54 | var data any 55 | resp, _, err := runQuery("GET", url, token, query, data) 56 | if err != nil { 57 | return Project{}, err 58 | } 59 | 60 | // unmarshal response body 61 | var project Project 62 | err = json.Unmarshal(resp.Data, &project) 63 | if err != nil { 64 | return Project{}, err 65 | } 66 | 67 | return project, nil 68 | } 69 | -------------------------------------------------------------------------------- /lib/commands/run.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/kionsoftware/kion-cli/lib/helper" 8 | "github.com/kionsoftware/kion-cli/lib/kion" 9 | "github.com/kionsoftware/kion-cli/lib/structs" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | // RunCommand generates creds for an AWS account then executes the user 14 | // provided command with said credentials set. 15 | func (c *Cmd) RunCommand(cCtx *cli.Context) error { 16 | // set vars for easier access 17 | favName := cCtx.String("favorite") 18 | accNum := cCtx.String("account") 19 | accAlias := cCtx.String("alias") 20 | carName := cCtx.String("car") 21 | region := c.config.Kion.DefaultRegion 22 | 23 | // placeholder for our stak 24 | var stak kion.STAK 25 | 26 | // prefer favorites if specified, else use account/alias and car 27 | if favName != "" { 28 | // map our favorites for ease of use 29 | _, fMap := helper.MapFavs(c.config.Favorites) 30 | 31 | // if arg passed is a valid favorite use it else error out 32 | var fav string 33 | var err error 34 | if fMap[favName] != (structs.Favorite{}) { 35 | fav = favName 36 | } else { 37 | return errors.New("can't find favorite") 38 | } 39 | 40 | // grab our favorite 41 | favorite := fMap[fav] 42 | 43 | // check if we have a valid cached stak else grab a new one 44 | cachedSTAK, found, err := c.cache.GetStak(favorite.CAR, favorite.Account, "") 45 | if err != nil { 46 | return err 47 | } 48 | if found && cachedSTAK.Expiration.After(time.Now().Add(-5*time.Second)) { 49 | stak = cachedSTAK 50 | } else { 51 | stak, err = c.authStakCache(cCtx, favorite.CAR, favorite.Account, "") 52 | if err != nil { 53 | return err 54 | } 55 | } 56 | 57 | // take the region flag over the favorite region 58 | targetRegion := region 59 | if targetRegion == "" { 60 | targetRegion = favorite.Region 61 | } 62 | 63 | // run the command 64 | err = helper.RunCommand(stak, targetRegion, cCtx.Args().First(), cCtx.Args().Tail()...) 65 | if err != nil { 66 | return err 67 | } 68 | } else { 69 | // check if we have a valid cached stak else grab a new one 70 | cachedSTAK, found, err := c.cache.GetStak(carName, accNum, accAlias) 71 | if err != nil { 72 | return err 73 | } 74 | if found && cachedSTAK.Expiration.After(time.Now().Add(-5*time.Second)) { 75 | stak = cachedSTAK 76 | } else { 77 | stak, err = c.authStakCache(cCtx, carName, accNum, accAlias) 78 | if err != nil { 79 | return err 80 | } 81 | } 82 | 83 | err = helper.RunCommand(stak, region, cCtx.Args().First(), cCtx.Args().Tail()...) 84 | if err != nil { 85 | return err 86 | } 87 | } 88 | 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /lib/kion/account.go: -------------------------------------------------------------------------------- 1 | package kion 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | //////////////////////////////////////////////////////////////////////////////// 9 | // // 10 | // Accounts // 11 | // // 12 | //////////////////////////////////////////////////////////////////////////////// 13 | 14 | // Account maps to the Kion API response for account data. 15 | type Account struct { 16 | Email string `json:"account_email"` 17 | Name string `json:"account_name"` 18 | Alias string `json:"account_alias"` 19 | Number string `json:"account_number"` 20 | TypeID uint `json:"account_type_id"` 21 | ID uint `json:"id"` 22 | IncludeLinkedAccountSpend bool `json:"include_linked_account_spend"` 23 | LinkedAccountNumber string `json:"linked_account_number"` 24 | LinkedRole string `json:"linked_role"` 25 | PayerID uint `json:"payer_id"` 26 | ProjectID uint `json:"project_id"` 27 | SkipAccessChecking bool `json:"skip_access_checking"` 28 | UseOrgAccountInfo bool `json:"use_org_account_info"` 29 | } 30 | 31 | // GetAccountsOnProject returns a list of Accounts associated with a given Kion 32 | // project. 33 | func GetAccountsOnProject(host string, token string, id uint) ([]Account, int, error) { 34 | // build our query and get response 35 | url := fmt.Sprintf("%v/api/v3/project/%v/accounts", host, id) 36 | query := map[string]string{} 37 | var data any 38 | resp, statusCode, err := runQuery("GET", url, token, query, data) 39 | if err != nil { 40 | return nil, statusCode, err 41 | } 42 | 43 | // unmarshal response body 44 | var accounts []Account 45 | jsonErr := json.Unmarshal(resp.Data, &accounts) 46 | if jsonErr != nil { 47 | return nil, 0, err 48 | } 49 | 50 | return accounts, resp.Status, nil 51 | } 52 | 53 | // GetAccount returns an account by the given account number. 54 | func GetAccount(host string, token string, accountNum string) (*Account, int, error) { 55 | // build our query and get response 56 | url := fmt.Sprintf("%v/api/v3/account/by-account-number/%v", host, accountNum) 57 | query := map[string]string{} 58 | var data any 59 | resp, statusCode, err := runQuery("GET", url, token, query, data) 60 | if err != nil { 61 | return nil, statusCode, err 62 | } 63 | 64 | // unmarshal response body 65 | var account Account 66 | jsonErr := json.Unmarshal(resp.Data, &account) 67 | if jsonErr != nil { 68 | return nil, 0, err 69 | } 70 | 71 | return &account, resp.Status, nil 72 | } 73 | -------------------------------------------------------------------------------- /lib/structs/configuration-structs.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | //////////////////////////////////////////////////////////////////////////////// 4 | // // 5 | // Structs // 6 | // // 7 | //////////////////////////////////////////////////////////////////////////////// 8 | 9 | // Configuration holds the CLI tool values needed to run. The struct maps to 10 | // the applications configured dotfile for persistence between sessions. 11 | type Configuration struct { 12 | Kion Kion `yaml:"kion,omitempty"` 13 | Favorites []Favorite `yaml:"favorites,omitempty"` 14 | Profiles map[string]Profile `yaml:"profiles,omitempty"` 15 | Browser Browser `yaml:"browser,omitempty"` 16 | } 17 | 18 | // Kion holds information about the instance of Kion with which the application 19 | // interfaces with as well as the credentials to do so. 20 | type Kion struct { 21 | URL string `yaml:"url,omitempty"` 22 | APIKey string `yaml:"api_key,omitempty"` 23 | Username string `yaml:"username,omitempty"` 24 | Password string `yaml:"password,omitempty"` 25 | IDMS string `yaml:"idms_id,omitempty"` 26 | SamlMetadataFile string `yaml:"saml_metadata_file,omitempty"` 27 | SamlIssuer string `yaml:"saml_sp_issuer,omitempty"` 28 | SamlPrintURL bool `yaml:"saml_print_url,omitempty"` 29 | DisableCache bool `yaml:"disable_cache,omitempty"` 30 | DefaultRegion string `yaml:"default_region,omitempty"` 31 | DebugMode bool `yaml:"debug_mode,omitempty"` 32 | QuietMode bool `yaml:"quiet_mode,omitempty"` 33 | } 34 | 35 | // Favorite holds information about user defined favorites used to quickly 36 | // access desired accounts. 37 | type Favorite struct { 38 | Name string `yaml:"name,omitempty" json:"alias_name"` 39 | Account string `yaml:"account,omitempty" json:"account_number"` 40 | CAR string `yaml:"cloud_access_role,omitempty" json:"cloud_access_role_name"` 41 | AccessType string `yaml:"access_type,omitempty" json:"access_type"` 42 | Region string `yaml:"region,omitempty" json:"account_region"` 43 | Service string `yaml:"service,omitempty"` 44 | FirefoxContainerName string `yaml:"firefox_container_name,omitempty"` 45 | CloudServiceProvider string `json:"cloud_service_provider"` 46 | DescriptiveName string 47 | Unaliased bool 48 | } 49 | 50 | // Profile holds an alternate configuration for Kion and Favorites. 51 | type Profile struct { 52 | Kion Kion `yaml:"kion,omitempty"` 53 | Favorites []Favorite `yaml:"favorites,omitempty"` 54 | } 55 | 56 | // Browser holds configurations for browser options. 57 | type Browser struct { 58 | FirefoxContainers bool `yaml:"firefox_containers,omitempty"` 59 | CustomBrowserPath string `yaml:"custom_browser_path,omitempty"` 60 | } 61 | -------------------------------------------------------------------------------- /lib/kion/auth.go: -------------------------------------------------------------------------------- 1 | package kion 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | //////////////////////////////////////////////////////////////////////////////// 9 | // // 10 | // Auth // 11 | // // 12 | //////////////////////////////////////////////////////////////////////////////// 13 | 14 | // Session maps to the session data returned by Kion after authentication. 15 | type Session struct { 16 | // ID int `json:"id"` 17 | IDMSID uint 18 | UserName string 19 | // UserID int `json:"user_id"` 20 | Access struct { 21 | Expiry string `json:"expiry"` 22 | Token string `json:"token"` 23 | // UserID int `json:"user_id"` 24 | } `json:"access"` 25 | Refresh struct { 26 | Expiry string `json:"expiry"` 27 | Token string `json:"token"` 28 | // UserID int `json:"user_id"` 29 | } `json:"refresh"` 30 | } 31 | 32 | // IDMS maps to the Kion API response for configured IDMSs. 33 | type IDMS struct { 34 | ID uint `json:"id"` 35 | IdmsTypeID uint `json:"idms_type_id"` 36 | Name string `json:"name"` 37 | } 38 | 39 | // AuthRequest maps to the required post body when interfacing with the Kion 40 | // API. 41 | type AuthRequest struct { 42 | IDMSID uint `json:"idms"` 43 | Username string `json:"username"` 44 | Password string `json:"password"` 45 | } 46 | 47 | // GetIDMSs queries the Kion API for all configured IDMS systems with which a 48 | // user can authenticate via username and password. 49 | func GetIDMSs(host string) ([]IDMS, error) { 50 | // build our query and get response 51 | url := fmt.Sprintf("%v/api/v2/idms", host) 52 | query := map[string]string{} 53 | var data any 54 | resp, _, err := runQuery("GET", url, "", query, data) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | // unmarshal response body 60 | var idmss []IDMS 61 | err = json.Unmarshal(resp.Data, &idmss) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | // only pass along idms's that can accept username and password via kion 67 | unpwIdmss := []IDMS{} 68 | for _, idms := range idmss { 69 | if idms.IdmsTypeID == 1 || idms.IdmsTypeID == 2 { 70 | unpwIdmss = append(unpwIdmss, idms) 71 | } 72 | } 73 | 74 | return unpwIdmss, nil 75 | } 76 | 77 | // Authenticate queries the Kion API to authenticate a user via username and 78 | // password. 79 | func Authenticate(host string, idmsID uint, un string, pw string) (Session, error) { 80 | // build our query and get response 81 | url := fmt.Sprintf("%v/api/v3/token", host) 82 | query := map[string]string{} 83 | data := AuthRequest{ 84 | IDMSID: idmsID, 85 | Username: un, 86 | Password: pw, 87 | } 88 | resp, _, err := runQuery("POST", url, "", query, data) 89 | if err != nil { 90 | return Session{}, err 91 | } 92 | 93 | // unmarshal response body 94 | var session Session 95 | err = json.Unmarshal(resp.Data, &session) 96 | if err != nil { 97 | return Session{}, err 98 | } 99 | 100 | return session, nil 101 | } 102 | -------------------------------------------------------------------------------- /lib/kion/favorite.go: -------------------------------------------------------------------------------- 1 | package kion 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/kionsoftware/kion-cli/lib/structs" 8 | ) 9 | 10 | //////////////////////////////////////////////////////////////////////////////// 11 | // // 12 | // Favorites // 13 | // // 14 | //////////////////////////////////////////////////////////////////////////////// 15 | 16 | // FavoritesResponse maps to the Kion API response. 17 | type FavoritesResponse struct { 18 | Status int `json:"status"` 19 | Favorites []structs.Favorite `json:"data"` 20 | } 21 | 22 | // GetAPIFavorites returns a list of a user's Favorites associated with a given Kion from the API 23 | func GetAPIFavorites(host string, token string) ([]structs.Favorite, int, error) { 24 | 25 | url := fmt.Sprintf("%v/api/v3/user-cloud-access-role-alias", host) 26 | query := map[string]string{} 27 | var data any 28 | resp, statusCode, err := runQuery("GET", url, token, query, data) 29 | if err != nil { 30 | return nil, statusCode, err 31 | } 32 | 33 | // unmarshal response body 34 | var favorites []structs.Favorite 35 | jsonErr := json.Unmarshal(resp.Data, &favorites) 36 | if jsonErr != nil { 37 | return nil, 0, err 38 | } 39 | 40 | var apiFavorites []structs.Favorite 41 | for _, apiFav := range favorites { 42 | 43 | // normalize the access type to match what the CLI uses 44 | apiFav.AccessType = ConvertAccessType(apiFav.AccessType) 45 | 46 | // handle upstream favorites with no alias 47 | if apiFav.Name == "" { 48 | apiFav.Name = "[unaliased]" 49 | apiFav.Unaliased = true 50 | } 51 | apiFavorites = append(apiFavorites, apiFav) 52 | } 53 | 54 | return apiFavorites, resp.Status, nil 55 | } 56 | 57 | func CreateFavorite(host string, token string, favorite structs.Favorite) (structs.Favorite, int, error) { 58 | url := fmt.Sprintf("%v/api/v3/user-cloud-access-role-alias", host) 59 | query := map[string]string{} 60 | data := map[string]string{ 61 | "alias_name": favorite.Name, 62 | "account_number": favorite.Account, 63 | "cloud_access_role_name": favorite.CAR, 64 | "access_type": favorite.AccessType, 65 | } 66 | resp, statusCode, err := runQuery("POST", url, token, query, data) 67 | if err != nil { 68 | return structs.Favorite{}, statusCode, err 69 | } 70 | 71 | // unmarshal response body 72 | var createdFav structs.Favorite 73 | jsonErr := json.Unmarshal(resp.Data, &createdFav) 74 | if jsonErr != nil { 75 | return structs.Favorite{}, 0, jsonErr 76 | } 77 | 78 | // check if the response is successful 79 | if statusCode != 201 && statusCode != 200 { 80 | return structs.Favorite{}, statusCode, fmt.Errorf("failed to create favorite: %s", resp.Message) 81 | } 82 | 83 | return createdFav, statusCode, nil 84 | } 85 | 86 | func DeleteFavorite(host string, token string, favoriteName string) (int, error) { 87 | url := fmt.Sprintf("%v/api/v3/user-cloud-access-role-alias", host) 88 | query := map[string]string{} 89 | data := map[string]string{"alias_name": favoriteName} 90 | resp, statusCode, err := runQuery("DELETE", url, token, query, data) 91 | if err != nil { 92 | return statusCode, err 93 | } 94 | 95 | // check if the response is successful 96 | if statusCode != 200 { 97 | return statusCode, fmt.Errorf("failed to delete favorite with name %s: %s", favoriteName, resp.Message) 98 | } 99 | 100 | return statusCode, nil 101 | } 102 | -------------------------------------------------------------------------------- /lib/cache/password-cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/99designs/keyring" 8 | ) 9 | 10 | //////////////////////////////////////////////////////////////////////////////// 11 | // // 12 | // Real Cacher // 13 | // // 14 | //////////////////////////////////////////////////////////////////////////////// 15 | 16 | // SetPassword stores a Password in the cache (or removes it if the password is nil). 17 | func (c *RealCache) SetPassword(host string, idmsID uint, un string, pw string) error { 18 | // set the key based on what was passed 19 | key := fmt.Sprintf("%s-%d-%s", host, idmsID, un) 20 | 21 | // pull our cache 22 | cacheName := "Kion-CLI Cache" 23 | cache, err := c.keyring.Get(cacheName) 24 | if err != nil && err != keyring.ErrKeyNotFound { 25 | return err 26 | } 27 | 28 | // unmarshal the json data 29 | var cacheData CacheData 30 | if len(cache.Data) > 0 { 31 | err = json.Unmarshal(cache.Data, &cacheData) 32 | if err != nil { 33 | return err 34 | } 35 | } 36 | 37 | // initialize the map if it is still nil 38 | if cacheData.PASSWORD == nil { 39 | cacheData.PASSWORD = make(map[string]string) 40 | } 41 | 42 | if pw != "" { 43 | // create/update our entry 44 | cacheData.PASSWORD[key] = pw 45 | } else { 46 | // Delete the entry 47 | delete(cacheData.PASSWORD, key) 48 | } 49 | 50 | // marshal the stack cache to json 51 | data, err := json.Marshal(cacheData) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | // build the keyring item 57 | cache = keyring.Item{ 58 | Key: cacheName, 59 | Data: data, 60 | Label: cacheName, 61 | Description: "Cache data for the Kion-CLI.", 62 | } 63 | 64 | // store the cache 65 | err = c.keyring.Set(cache) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | return nil 71 | } 72 | 73 | // GetPassword retrieves a password from the cache. 74 | func (c *RealCache) GetPassword(host string, idmsID uint, un string) (string, bool, error) { 75 | // set the key based on what was passed 76 | key := fmt.Sprintf("%s-%d-%s", host, idmsID, un) 77 | 78 | // pull our cache 79 | cache, err := c.keyring.Get("Kion-CLI Cache") 80 | if err != nil { 81 | if err == keyring.ErrKeyNotFound { 82 | return "", false, nil 83 | } 84 | return "", false, err 85 | } 86 | 87 | // unmarshal the json data 88 | var cacheData CacheData 89 | if len(cache.Data) > 0 { 90 | err = json.Unmarshal(cache.Data, &cacheData) 91 | if err != nil { 92 | return "", false, err 93 | } 94 | } 95 | 96 | // return the password if found 97 | password, found := cacheData.PASSWORD[key] 98 | if found { 99 | return password, true, nil 100 | } 101 | 102 | // return empty password if not found 103 | return "", false, nil 104 | } 105 | 106 | //////////////////////////////////////////////////////////////////////////////// 107 | // // 108 | // Null Cacher // 109 | // // 110 | //////////////////////////////////////////////////////////////////////////////// 111 | 112 | // SetPassword does nothing. 113 | func (c *NullCache) SetPassword(host string, idmsID uint, un string, pw string) error { 114 | return nil 115 | } 116 | 117 | // GetPassword returns an empty password, false, and a nil error. 118 | func (c *NullCache) GetPassword(host string, idmsID uint, un string) (string, bool, error) { 119 | return "", false, nil 120 | } 121 | -------------------------------------------------------------------------------- /lib/commands/stak.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/kionsoftware/kion-cli/lib/helper" 8 | "github.com/kionsoftware/kion-cli/lib/kion" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | // GenStaks generates short term access keys by walking users through an 13 | // interactive prompt. Short term access keys are either printed to stdout or a 14 | // sub-shell is created with them set in the environment. 15 | func (c *Cmd) GenStaks(cCtx *cli.Context) error { 16 | // stub out placeholders 17 | var car kion.CAR 18 | var stak kion.STAK 19 | 20 | // set vars for easier access 21 | endpoint := c.config.Kion.URL 22 | carName := cCtx.String("car") 23 | accNum := cCtx.String("account") 24 | accAlias := cCtx.String("alias") 25 | region := c.config.Kion.DefaultRegion 26 | 27 | // get command used and set cache validity buffer 28 | action, buffer := getActionAndBuffer(cCtx) 29 | 30 | // if we have what we need go look stuff up without prompts do it 31 | if (accNum != "" || accAlias != "") && carName != "" { 32 | // determine if we have a valid cached entry 33 | cachedSTAK, found, err := c.cache.GetStak(carName, accNum, accAlias) 34 | if err != nil { 35 | return err 36 | } 37 | getCar := true 38 | if found && cachedSTAK.Expiration.After(time.Now().Add(-buffer*time.Second)) { 39 | // cached stak found and is still valid 40 | stak = cachedSTAK 41 | if action != "subshell" && action != "save" { 42 | // skip getting the car for everything but subshell and save 43 | getCar = false 44 | } 45 | } 46 | 47 | // grab the car if needed 48 | if getCar { 49 | // handle auth 50 | err := c.setAuthToken(cCtx) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | if accNum != "" { 56 | car, err = kion.GetCARByNameAndAccount(endpoint, c.config.Kion.APIKey, carName, accNum) 57 | if err != nil { 58 | return err 59 | } 60 | } else { 61 | car, err = kion.GetCARByNameAndAlias(endpoint, c.config.Kion.APIKey, carName, accAlias) 62 | if err != nil { 63 | return err 64 | } 65 | } 66 | } 67 | } else { 68 | // handle auth 69 | err := c.setAuthToken(cCtx) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | // run through the car selector to fill any gaps 75 | err = helper.CARSelector(cCtx, &car) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | // rebuild cache key and determine if we have a valid cached entry 81 | cachedSTAK, found, err := c.cache.GetStak(car.Name, car.AccountNumber, "") 82 | if err != nil { 83 | return err 84 | } 85 | if found && cachedSTAK.Expiration.After(time.Now().Add(-buffer*time.Second)) { 86 | // cached stak found and is still valid 87 | stak = cachedSTAK 88 | } 89 | } 90 | 91 | // grab a new stak if needed 92 | if stak == (kion.STAK{}) { 93 | var err error 94 | stak, err = c.authStakCache(cCtx, car.Name, car.AccountNumber, car.AccountAlias) 95 | if err != nil { 96 | return err 97 | } 98 | } 99 | 100 | // run the action 101 | switch action { 102 | case "credential-process": 103 | // NOTE: do not use os.Stderr here else credentials can be written to logs 104 | return helper.PrintCredentialProcess(os.Stdout, stak) 105 | case "print": 106 | return helper.PrintSTAK(os.Stdout, stak, region) 107 | case "save": 108 | return helper.SaveAWSCreds(stak, car) 109 | case "subshell": 110 | if !c.config.Kion.QuietMode { 111 | if err := helper.PrintFavoriteConfig(os.Stdout, car, region, "cli"); err != nil { 112 | return err 113 | } 114 | } 115 | var displayAlais string 116 | if accAlias != "" { 117 | displayAlais = accAlias 118 | } else { 119 | displayAlais = car.AccountName 120 | } 121 | return helper.CreateSubShell(car.AccountNumber, displayAlais, car.Name, stak, region) 122 | default: 123 | return nil 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /lib/cache/session-cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/99designs/keyring" 7 | "github.com/kionsoftware/kion-cli/lib/kion" 8 | ) 9 | 10 | // SetSession is a common func for all Cache implementations and stores a 11 | // Session in the cache. 12 | func setSession(k keyring.Keyring, session kion.Session) error { 13 | // pull our stak cache 14 | cacheName := "Kion-CLI Cache" 15 | cache, err := k.Get(cacheName) 16 | if err != nil && err != keyring.ErrKeyNotFound { 17 | return err 18 | } 19 | 20 | // unmarshal the json data 21 | var cacheData CacheData 22 | if len(cache.Data) > 0 { 23 | err = json.Unmarshal(cache.Data, &cacheData) 24 | if err != nil { 25 | return err 26 | } 27 | } 28 | 29 | // store the session 30 | cacheData.SESSION = session 31 | 32 | // marshal the stack cache to json 33 | data, err := json.Marshal(cacheData) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | // build the keyring item 39 | cache = keyring.Item{ 40 | Key: cacheName, 41 | Data: data, 42 | Label: cacheName, 43 | Description: "Cache data for the Kion-CLI.", 44 | } 45 | 46 | // store the cache 47 | err = k.Set(cache) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | return nil 53 | 54 | } 55 | 56 | // GetSession is a common func for all Cache implementations and retrieves a 57 | // Session in the cache. 58 | func getSession(k keyring.Keyring) (kion.Session, bool, error) { 59 | // pull our stak cache 60 | cache, err := k.Get("Kion-CLI Cache") 61 | if err != nil { 62 | if err == keyring.ErrKeyNotFound { 63 | return kion.Session{}, false, nil 64 | } 65 | return kion.Session{}, false, err 66 | } 67 | 68 | // unmarshal the json data 69 | var cacheData CacheData 70 | if len(cache.Data) > 0 { 71 | err = json.Unmarshal(cache.Data, &cacheData) 72 | if err != nil { 73 | return kion.Session{}, false, err 74 | } 75 | } 76 | 77 | // return the stak if found 78 | session := cacheData.SESSION 79 | if session != (kion.Session{}) { 80 | return session, true, nil 81 | } 82 | 83 | // return empty stak if not found 84 | return kion.Session{}, false, nil 85 | } 86 | 87 | //////////////////////////////////////////////////////////////////////////////// 88 | // // 89 | // Real Cacher // 90 | // // 91 | //////////////////////////////////////////////////////////////////////////////// 92 | 93 | // SetSession implements the Cache interface for RealCache and wraps a common 94 | // function for storing session data. 95 | func (c *RealCache) SetSession(session kion.Session) error { 96 | return setSession(c.keyring, session) 97 | } 98 | 99 | // GetSession implements the Cache interface for RealCache and wraps a common 100 | // function for retrieving session data. 101 | func (c *RealCache) GetSession() (kion.Session, bool, error) { 102 | return getSession(c.keyring) 103 | } 104 | 105 | //////////////////////////////////////////////////////////////////////////////// 106 | // // 107 | // Null Cacher // 108 | // // 109 | //////////////////////////////////////////////////////////////////////////////// 110 | 111 | // SetSession implements the Cache interface for NullCache and does nothing. 112 | func (c *NullCache) SetSession(session kion.Session) error { 113 | return nil 114 | } 115 | 116 | // GetSession implements the Cache interface for NullCache and returns an empty session, false, and a nil error. 117 | func (c *NullCache) GetSession() (kion.Session, bool, error) { 118 | return kion.Session{}, false, nil 119 | } 120 | -------------------------------------------------------------------------------- /lib/cache/stak-cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/99designs/keyring" 10 | "github.com/kionsoftware/kion-cli/lib/kion" 11 | ) 12 | 13 | //////////////////////////////////////////////////////////////////////////////// 14 | // // 15 | // Real Cacher // 16 | // // 17 | //////////////////////////////////////////////////////////////////////////////// 18 | 19 | // SetStak stores a STAK in the cache. 20 | func (c *RealCache) SetStak(carName string, accNum string, accAlias string, value kion.STAK) error { 21 | // set the key based on what was passed 22 | var key string 23 | if accNum != "" { 24 | key = fmt.Sprintf("%s-%s", carName, accNum) 25 | } else { 26 | key = fmt.Sprintf("%s-%s", carName, strings.ToLower(accAlias)) 27 | } 28 | 29 | // pull our stak cache 30 | cacheName := "Kion-CLI Cache" 31 | cache, err := c.keyring.Get(cacheName) 32 | if err != nil && err != keyring.ErrKeyNotFound { 33 | return err 34 | } 35 | 36 | // unmarshal the json data 37 | var cacheData CacheData 38 | if len(cache.Data) > 0 { 39 | err = json.Unmarshal(cache.Data, &cacheData) 40 | if err != nil { 41 | return err 42 | } 43 | } 44 | 45 | // initialize the map if it is still nil 46 | if cacheData.STAK == nil { 47 | cacheData.STAK = make(map[string]kion.STAK) 48 | } 49 | 50 | // clean expired entries 51 | now := time.Now() 52 | for key, stak := range cacheData.STAK { 53 | if stak.Expiration.Before(now) { 54 | delete(cacheData.STAK, key) 55 | } 56 | } 57 | 58 | // create our entry 59 | cacheData.STAK[key] = value 60 | 61 | // marshal the stack cache to json 62 | data, err := json.Marshal(cacheData) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | // build the keyring item 68 | cache = keyring.Item{ 69 | Key: cacheName, 70 | Data: data, 71 | Label: cacheName, 72 | Description: "Cache data for the Kion-CLI.", 73 | } 74 | 75 | // store the cache 76 | err = c.keyring.Set(cache) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | return nil 82 | } 83 | 84 | // GetStak retrieves a STAK from the cache. 85 | func (c *RealCache) GetStak(carName string, accNum string, accAlias string) (kion.STAK, bool, error) { 86 | // set the key based on what was passed 87 | var key string 88 | if accNum != "" { 89 | key = fmt.Sprintf("%s-%s", carName, accNum) 90 | } else { 91 | key = fmt.Sprintf("%s-%s", carName, strings.ToLower(accAlias)) 92 | } 93 | 94 | // pull our stak cache 95 | cache, err := c.keyring.Get("Kion-CLI Cache") 96 | if err != nil { 97 | if err == keyring.ErrKeyNotFound { 98 | return kion.STAK{}, false, nil 99 | } 100 | return kion.STAK{}, false, err 101 | } 102 | 103 | // unmarshal the json data 104 | var cacheData CacheData 105 | if len(cache.Data) > 0 { 106 | err = json.Unmarshal(cache.Data, &cacheData) 107 | if err != nil { 108 | return kion.STAK{}, false, err 109 | } 110 | } 111 | 112 | // return the stak if found 113 | stak, found := cacheData.STAK[key] 114 | if found { 115 | return stak, true, nil 116 | } 117 | 118 | // return empty stak if not found 119 | return kion.STAK{}, false, nil 120 | } 121 | 122 | //////////////////////////////////////////////////////////////////////////////// 123 | // // 124 | // Null Cacher // 125 | // // 126 | //////////////////////////////////////////////////////////////////////////////// 127 | 128 | // SetStak does nothing. 129 | func (c *NullCache) SetStak(carName string, accNum string, accAlias string, value kion.STAK) error { 130 | return nil 131 | } 132 | 133 | // GetStak returns an empty STAK, false, and a nil error. 134 | func (c *NullCache) GetStak(carName string, accNum string, accAlias string) (kion.STAK, bool, error) { 135 | return kion.STAK{}, false, nil 136 | } 137 | -------------------------------------------------------------------------------- /lib/kion/kion.go: -------------------------------------------------------------------------------- 1 | // Package kion provides functions to interact with the Kion API. 2 | package kion 3 | 4 | import ( 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | type APIRespBody struct { 14 | Status int `json:"status"` 15 | Message string `json:"message"` 16 | Data json.RawMessage `json:"data"` 17 | } 18 | 19 | //////////////////////////////////////////////////////////////////////////////// 20 | // // 21 | // Helpers // 22 | // // 23 | //////////////////////////////////////////////////////////////////////////////// 24 | 25 | // runQuery performs queries against the Kion API. 26 | func runQuery(method string, url string, token string, query map[string]string, payload any) (APIRespBody, int, error) { 27 | // prepare our response struct 28 | apiResp := APIRespBody{} 29 | 30 | // prepare the request body 31 | reqBody, err := json.Marshal(payload) 32 | if err != nil { 33 | return apiResp, 0, err 34 | } 35 | 36 | // start our request 37 | req, err := http.NewRequest(method, url, bytes.NewBuffer(reqBody)) 38 | if err != nil { 39 | return apiResp, 0, err 40 | } 41 | 42 | // append on our parameters to the req.URL.String() 43 | q := req.URL.Query() 44 | for key, value := range query { 45 | q.Add(key, value) 46 | } 47 | req.URL.RawQuery = q.Encode() 48 | 49 | // add authorization header to the req 50 | if token != "" { 51 | req.Header.Add("Authorization", "Bearer "+token) 52 | } 53 | 54 | // identify the source of the request 55 | req.Header.Add("kion-source", "kion-cli") 56 | 57 | // send the request 58 | client := &http.Client{} 59 | resp, err := client.Do(req) 60 | if err != nil { 61 | return apiResp, 0, err 62 | } 63 | defer resp.Body.Close() 64 | 65 | // get the body of the response 66 | respBody, err := io.ReadAll(resp.Body) 67 | if err != nil { 68 | return apiResp, 0, err 69 | } 70 | 71 | err = json.Unmarshal(respBody, &apiResp) 72 | if err != nil { 73 | return apiResp, resp.StatusCode, err 74 | } 75 | 76 | // handle non 200's 77 | if resp.StatusCode != 200 && resp.StatusCode != 201 { 78 | return apiResp, resp.StatusCode, fmt.Errorf("[%v] %v", resp.StatusCode, apiResp.Message) 79 | } 80 | 81 | // return the response 82 | return apiResp, resp.StatusCode, nil 83 | } 84 | 85 | //////////////////////////////////////////////////////////////////////////////// 86 | // // 87 | // Kion Configurations // 88 | // // 89 | //////////////////////////////////////////////////////////////////////////////// 90 | 91 | // GetVersion returns the targeted Kion's version number. 92 | func GetVersion(host string) (string, error) { 93 | url := fmt.Sprintf("%v/api/version", host) 94 | query := map[string]string{} 95 | var data any 96 | resp, _, err := runQuery("GET", url, "", query, data) 97 | if err != nil { 98 | return "", err 99 | } 100 | 101 | // unmarshal response body 102 | var version string 103 | err = json.Unmarshal(resp.Data, &version) 104 | if err != nil { 105 | return "", err 106 | } 107 | 108 | // remove any dev suffixes 109 | version = strings.Split(version, "-")[0] 110 | 111 | return version, nil 112 | } 113 | 114 | // GetSessionDuration returns the AWS session duration configuration Kion uses 115 | // to generate session tokens. If 403 is received, we assume the shortest 116 | // setting of 15 minutes. 117 | func GetSessionDuration(host string, token string) (int, error) { 118 | url := fmt.Sprintf("%v/api/v3/app-config/aws-access", host) 119 | query := map[string]string{} 120 | var data any 121 | resp, status, err := runQuery("GET", url, token, query, data) 122 | if err != nil { 123 | if status == 403 { 124 | return 15, nil 125 | } else { 126 | return 0, err 127 | } 128 | } 129 | 130 | // unmarshal response body 131 | var response struct { 132 | Duration int `json:"aws_temporary_credentials_duration"` 133 | } 134 | err = json.Unmarshal(resp.Data, &response) 135 | if err != nil { 136 | return 0, err 137 | } 138 | 139 | return response.Duration, nil 140 | } 141 | 142 | // ConvertAccessType converts the access type string between what the API uses 143 | // and the CLI. It converts "console_access" to "web", and vice versa, and 144 | // "short_term_key_access" to "cli" and vice versa. If the access type does 145 | // not match any of these, it returns the original string. 146 | func ConvertAccessType(accessType string) string { 147 | switch accessType { 148 | case "console_access": 149 | return "web" 150 | case "short_term_key_access": 151 | return "cli" 152 | case "web": 153 | return "console_access" 154 | case "cli": 155 | return "short_term_key_access" 156 | default: 157 | return accessType 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /lib/helper/shell.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | "syscall" 11 | 12 | "github.com/fatih/color" 13 | "github.com/kionsoftware/kion-cli/lib/kion" 14 | ) 15 | 16 | //////////////////////////////////////////////////////////////////////////////// 17 | // // 18 | // Shell // 19 | // // 20 | //////////////////////////////////////////////////////////////////////////////// 21 | 22 | // CreateSubShell creates a sub-shell containing set variables for AWS short 23 | // term access keys. It attempts to use the users configured shell and rc file 24 | // while overriding the prompt to indicate the authed AWS account. 25 | func CreateSubShell(accountNumber string, accountAlias string, carName string, stak kion.STAK, region string) error { 26 | // check if we know the account name 27 | var accountMeta string 28 | var accountMetaSentence string 29 | if accountAlias == "" { 30 | accountMeta = accountNumber 31 | accountMetaSentence = accountNumber 32 | } else { 33 | if runtime.GOOS == "windows" { 34 | accountMeta = fmt.Sprintf("%v^|%v", accountAlias, accountNumber) 35 | } else { 36 | accountMeta = fmt.Sprintf("%v|%v", accountAlias, accountNumber) 37 | } 38 | accountMetaSentence = fmt.Sprintf("%v (%v)", accountAlias, accountNumber) 39 | } 40 | 41 | // get users shell information 42 | usrShellPath := os.Getenv("SHELL") 43 | usrShellName := filepath.Base(usrShellPath) 44 | usrHistFile := os.Getenv("HISTFILE") 45 | 46 | // create command based on the users shell and set prompt 47 | var cmd string 48 | switch usrShellName { 49 | case "zsh": 50 | zdotdir, err := os.MkdirTemp("", "kionzrootdir") 51 | if err != nil { 52 | return err 53 | } 54 | defer os.RemoveAll(zdotdir) 55 | f, err := os.Create(zdotdir + "/.zshrc") 56 | if err != nil { 57 | return err 58 | } 59 | fmt.Fprintf(f, `HISTFILE=%s; source $HOME/.zshrc; autoload -U colors && colors; export PS1="%%F{green}[%v]%%b%%f $PS1"`, usrHistFile, accountMeta) 60 | err = f.Sync() 61 | if err != nil { 62 | return err 63 | } 64 | cmd = fmt.Sprintf(`ZDOTDIR=%v zsh`, zdotdir) 65 | case "bash": 66 | cmd = fmt.Sprintf(`bash --rcfile <(echo "source \"$HOME/.bashrc\"; export PS1='[%v] > '")`, accountMeta) 67 | default: 68 | cmd = fmt.Sprintf(`bash --rcfile <(echo "source \"$HOME/.bashrc\"; export PS1='[%v] > '")`, accountMeta) 69 | } 70 | 71 | // init shell 72 | var shell *exec.Cmd 73 | if runtime.GOOS == "windows" && usrShellName == "" { 74 | cmdPath := "C:\\Windows\\System32\\cmd.exe" 75 | shell = exec.Command(cmdPath, "/K", fmt.Sprintf(`PROMPT $E[32m[%s]$E[0m$G`, accountMeta)) 76 | } else { 77 | shell = exec.Command("bash", "-c", cmd) 78 | } 79 | 80 | // replicate current env vars and add stak 81 | shell.Env = os.Environ() 82 | shell.Env = append(shell.Env, fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", stak.AccessKey)) 83 | shell.Env = append(shell.Env, fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", stak.SecretAccessKey)) 84 | shell.Env = append(shell.Env, fmt.Sprintf("AWS_SESSION_TOKEN=%s", stak.SessionToken)) 85 | shell.Env = append(shell.Env, fmt.Sprintf("KION_ACCOUNT_NUM=%s", accountNumber)) 86 | shell.Env = append(shell.Env, fmt.Sprintf("KION_ACCOUNT_ALIAS=%s", accountAlias)) 87 | shell.Env = append(shell.Env, fmt.Sprintf("KION_CAR=%s", carName)) 88 | 89 | // set region if one was passed 90 | if region != "" { 91 | shell.Env = append(shell.Env, fmt.Sprintf("AWS_REGION=%s", region)) 92 | } 93 | 94 | // configure file handlers 95 | shell.Stdin = os.Stdin 96 | shell.Stdout = os.Stdout 97 | shell.Stderr = os.Stderr 98 | 99 | // run the shell 100 | color.Green("Starting session for %v", accountMetaSentence) 101 | err := shell.Run() 102 | color.Green("Shutting down session for %v", accountMetaSentence) 103 | 104 | return err 105 | } 106 | 107 | // RunCommand executes a one time command with AWS credentials set within the 108 | // environment. Command output is sent directly to stdout / stderr. 109 | func RunCommand(stak kion.STAK, region string, cmd string, args ...string) error { 110 | // stub out an empty command stack 111 | newCmd := make([]string, 0) 112 | 113 | // if we can't find a binary, assume it's a shell alias and prep a sub-shell call, otherwise use the binary path 114 | binary, err := exec.LookPath(cmd) 115 | if len(binary) < 1 || err != nil { 116 | sh := os.Getenv("SHELL") 117 | if strings.HasSuffix(sh, "/bash") || strings.HasSuffix(sh, "/fish") || strings.HasSuffix(sh, "/zsh") || strings.HasSuffix(sh, "/ksh") { 118 | newCmd = append(newCmd, sh, "-i", "-c", cmd) 119 | } 120 | } else { 121 | newCmd = append(newCmd, binary) 122 | } 123 | 124 | // replicate current env vars and add stak 125 | env := os.Environ() 126 | env = append(env, fmt.Sprintf("AWS_ACCESS_KEY_ID=%s", stak.AccessKey)) 127 | env = append(env, fmt.Sprintf("AWS_SECRET_ACCESS_KEY=%s", stak.SecretAccessKey)) 128 | env = append(env, fmt.Sprintf("AWS_SESSION_TOKEN=%s", stak.SessionToken)) 129 | 130 | // set region if one was passed 131 | if region != "" { 132 | env = append(env, fmt.Sprintf("AWS_REGION=%s", region)) 133 | } 134 | 135 | // moosh it all together 136 | newCmd = append(newCmd, args...) 137 | 138 | err = syscall.Exec(newCmd[0], newCmd[0:], env) 139 | return err 140 | } 141 | -------------------------------------------------------------------------------- /lib/commands/favorite.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "time" 8 | 9 | "github.com/kionsoftware/kion-cli/lib/helper" 10 | "github.com/kionsoftware/kion-cli/lib/kion" 11 | "github.com/kionsoftware/kion-cli/lib/structs" 12 | "github.com/urfave/cli/v2" 13 | ) 14 | 15 | func (c *Cmd) getFavorites(cCtx *cli.Context) ([]structs.Favorite, error) { 16 | // get the combined list of favorites from the CLI config and the Kion API (if compatible) 17 | useAPI := cCtx.App.Metadata["useFavoritesAPI"].(bool) 18 | var apiFavorites []structs.Favorite 19 | var combinedFavorites []structs.Favorite 20 | var err error 21 | if useAPI { 22 | // Authenticate before making API calls 23 | err = c.setAuthToken(cCtx) 24 | if err != nil { 25 | return apiFavorites, err 26 | } 27 | apiFavorites, _, err = kion.GetAPIFavorites(c.config.Kion.URL, c.config.Kion.APIKey) 28 | if err != nil { 29 | fmt.Printf("Error retrieving favorites from API: %v\n", err) 30 | return apiFavorites, err 31 | } 32 | } 33 | combinedFavorites, _, err = helper.CombineFavorites(c.config.Favorites, apiFavorites) 34 | if err != nil { 35 | fmt.Printf("Error combining favorites: %v\n", err) 36 | return combinedFavorites, err 37 | } 38 | return combinedFavorites, nil 39 | } 40 | 41 | // ListFavorites prints out the users stored favorites and favorites from the 42 | // Kion API. Extra information is provided if the verbose flag is set. 43 | func (c *Cmd) ListFavorites(cCtx *cli.Context) error { 44 | 45 | favorites, err := c.getFavorites(cCtx) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | // sort favorites by name 51 | sort.Slice(favorites, func(i, j int) bool { 52 | return favorites[i].Name < favorites[j].Name 53 | }) 54 | 55 | // print it out 56 | if cCtx.Bool("verbose") { 57 | for _, f := range favorites { 58 | accessType := f.AccessType 59 | if accessType == "" { 60 | accessType = "cli (Default)" 61 | } 62 | region := f.Region 63 | if region == "" { 64 | region = "[unset]" 65 | } 66 | fmt.Printf(" %v:\n account number: %v\n cloud access role: %v\n access type: %v\n region: %v\n", f.Name, f.Account, f.CAR, accessType, region) 67 | } 68 | } else { 69 | for _, f := range favorites { 70 | fmt.Printf(" %v\n", f.DescriptiveName) 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | 77 | // Favorites generates short term access keys or launches the web console 78 | // from stored favorites. If a favorite is found that matches the passed 79 | // argument it is used, otherwise the user is walked through a wizard to make a 80 | // selection. 81 | func (c *Cmd) Favorites(cCtx *cli.Context) error { 82 | 83 | favorites, err := c.getFavorites(cCtx) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | // run favorites through MapFavs 89 | fNames, fMap := helper.MapFavs(favorites) 90 | 91 | // if arg passed is a valid favorite use it else prompt 92 | var fav string 93 | if fMap[cCtx.Args().First()] != (structs.Favorite{}) { 94 | fav = cCtx.Args().First() 95 | } else { 96 | fav, err = helper.PromptSelect("Choose a Favorite:", fNames) 97 | if err != nil { 98 | return err 99 | } 100 | } 101 | 102 | // grab the favorite object 103 | favorite := fMap[fav] 104 | 105 | // override access type if explicitly set 106 | if cCtx.String("access-type") != "" { 107 | favorite.AccessType = cCtx.String("access-type") 108 | } 109 | 110 | // have --web flag take precedence over access-type 111 | if cCtx.Bool("web") { 112 | favorite.AccessType = "web" 113 | } 114 | 115 | // determine favorite action, default to cli unless explicitly set to web 116 | if favorite.AccessType == "web" { 117 | // handle auth 118 | err = c.setAuthToken(cCtx) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | // attempt to find an exact match then fallback to the first match 124 | car, err := kion.GetCARByNameAndAccount(c.config.Kion.URL, c.config.Kion.APIKey, favorite.CAR, favorite.Account) 125 | if err != nil { 126 | car, err = kion.GetCARByName(c.config.Kion.URL, c.config.Kion.APIKey, favorite.CAR) 127 | if err != nil { 128 | return err 129 | } 130 | car.AccountNumber = favorite.Account 131 | } 132 | 133 | url, err := kion.GetFederationURL(c.config.Kion.URL, c.config.Kion.APIKey, car) 134 | if err != nil { 135 | return err 136 | } 137 | fmt.Printf("Federating into %s (%s) via %s\n", favorite.Name, favorite.Account, car.AwsIamRoleName) 138 | session := structs.SessionInfo{ 139 | AccountName: favorite.Name, 140 | AccountNumber: car.AccountNumber, 141 | AccountTypeID: car.AccountTypeID, 142 | AwsIamRoleName: car.AwsIamRoleName, 143 | Region: favorite.Region, 144 | } 145 | return helper.OpenBrowserRedirect(url, session, c.config.Browser, favorite.Service, favorite.FirefoxContainerName) 146 | } else { 147 | // placeholder for our stak 148 | var stak kion.STAK 149 | 150 | // determine action and set required cache validity buffer 151 | action, buffer := getActionAndBuffer(cCtx) 152 | 153 | // check if we have a valid cached stak else grab a new one 154 | cachedSTAK, found, err := c.cache.GetStak(favorite.CAR, favorite.Account, "") 155 | if err != nil { 156 | return err 157 | } 158 | if found && cachedSTAK.Expiration.After(time.Now().Add(-buffer*time.Second)) { 159 | stak = cachedSTAK 160 | } else { 161 | stak, err = c.authStakCache(cCtx, favorite.CAR, favorite.Account, "") 162 | if err != nil { 163 | return err 164 | } 165 | } 166 | 167 | // credential process output, print, or create sub-shell 168 | switch action { 169 | case "credential-process": 170 | // NOTE: Do not use os.Stderr here else credentials can be written to logs 171 | return helper.PrintCredentialProcess(os.Stdout, stak) 172 | case "print": 173 | return helper.PrintSTAK(os.Stdout, stak, favorite.Region) 174 | case "subshell": 175 | return helper.CreateSubShell(favorite.Account, favorite.Name, favorite.CAR, stak, favorite.Region) 176 | default: 177 | return nil 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /lib/helper/output_test.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "testing" 8 | "time" 9 | 10 | "github.com/kionsoftware/kion-cli/lib/kion" 11 | ) 12 | 13 | func TestPrintSTAK(t *testing.T) { 14 | tests := []struct { 15 | description string 16 | stak kion.STAK 17 | region string 18 | want string 19 | }{ 20 | { 21 | "Empty", 22 | kion.STAK{}, 23 | "", 24 | "export AWS_ACCESS_KEY_ID=\nexport AWS_SECRET_ACCESS_KEY=\nexport AWS_SESSION_TOKEN=\n", 25 | }, 26 | // { 27 | // "Panic Condition", 28 | // kion.STAK{}, 29 | // "panic", 30 | // }, 31 | { 32 | "Partial STAK", 33 | kion.STAK{ 34 | AccessKey: "", 35 | SecretAccessKey: "aBCDeFg1hijkl2m3NOPqr4StUvWxY56z7abc8DEf", 36 | SessionToken: "", 37 | }, 38 | "", 39 | "export AWS_ACCESS_KEY_ID=\nexport AWS_SECRET_ACCESS_KEY=aBCDeFg1hijkl2m3NOPqr4StUvWxY56z7abc8DEf\nexport AWS_SESSION_TOKEN=\n", 40 | }, 41 | { 42 | "Full STAK", 43 | kion.STAK{ 44 | AccessKey: "ASIAABCDEFGHIJ1K23LM", 45 | SecretAccessKey: "aBCDeFg1hijkl2m3NOPqr4StUvWxY56z7abc8DEf", 46 | SessionToken: "AbcDEFghIJKlMNoPQrStuVwXYZabcDEfGhI1JklmNoPQRStu2VWXYZaBcd34ef+GH+IJKLmNOPQRSTU5VwxyzABcdeFGHIj6KlMNoPQ7rSTUvW8X9yZAbCD0ef+gHIJkLMnoPqrstUVwxyzAb1CD2e34fgHiJKlMnOPqr56STuvwXyzABcdEfgh7IJK+8LM91No2pqrSTuvWxyz3ABCdEFGH4ijklMNOP5qrs6TUvWxyz789abcDefgH12iJKlM3no4pQRs+5t6UVw7/xy+ZaBcdE+FGhIj8kLmnOpqrstuvw9xyzab1cD/ef23GhIjkLMNoPQrstuv=", 47 | }, 48 | "", 49 | "export AWS_ACCESS_KEY_ID=ASIAABCDEFGHIJ1K23LM\nexport AWS_SECRET_ACCESS_KEY=aBCDeFg1hijkl2m3NOPqr4StUvWxY56z7abc8DEf\nexport AWS_SESSION_TOKEN=AbcDEFghIJKlMNoPQrStuVwXYZabcDEfGhI1JklmNoPQRStu2VWXYZaBcd34ef+GH+IJKLmNOPQRSTU5VwxyzABcdeFGHIj6KlMNoPQ7rSTUvW8X9yZAbCD0ef+gHIJkLMnoPqrstUVwxyzAb1CD2e34fgHiJKlMnOPqr56STuvwXyzABcdEfgh7IJK+8LM91No2pqrSTuvWxyz3ABCdEFGH4ijklMNOP5qrs6TUvWxyz789abcDefgH12iJKlM3no4pQRs+5t6UVw7/xy+ZaBcdE+FGhIj8kLmnOpqrstuvw9xyzab1cD/ef23GhIjkLMNoPQrstuv=\n", 50 | }, 51 | { 52 | "With Region", 53 | kion.STAK{ 54 | AccessKey: "ASIAABCDEFGHIJ1K23LM", 55 | SecretAccessKey: "aBCDeFg1hijkl2m3NOPqr4StUvWxY56z7abc8DEf", 56 | SessionToken: "AbcDEFghIJKlMNoPQrStuVwXYZabcDEfGhI1JklmNoPQRStu2VWXYZaBcd34ef+GH+IJKLmNOPQRSTU5VwxyzABcdeFGHIj6KlMNoPQ7rSTUvW8X9yZAbCD0ef+gHIJkLMnoPqrstUVwxyzAb1CD2e34fgHiJKlMnOPqr56STuvwXyzABcdEfgh7IJK+8LM91No2pqrSTuvWxyz3ABCdEFGH4ijklMNOP5qrs6TUvWxyz789abcDefgH12iJKlM3no4pQRs+5t6UVw7/xy+ZaBcdE+FGhIj8kLmnOpqrstuvw9xyzab1cD/ef23GhIjkLMNoPQrstuv=", 57 | }, 58 | "us-gov-west-1", 59 | "export AWS_REGION=us-gov-west-1\nexport AWS_ACCESS_KEY_ID=ASIAABCDEFGHIJ1K23LM\nexport AWS_SECRET_ACCESS_KEY=aBCDeFg1hijkl2m3NOPqr4StUvWxY56z7abc8DEf\nexport AWS_SESSION_TOKEN=AbcDEFghIJKlMNoPQrStuVwXYZabcDEfGhI1JklmNoPQRStu2VWXYZaBcd34ef+GH+IJKLmNOPQRSTU5VwxyzABcdeFGHIj6KlMNoPQ7rSTUvW8X9yZAbCD0ef+gHIJkLMnoPqrstUVwxyzAb1CD2e34fgHiJKlMnOPqr56STuvwXyzABcdEfgh7IJK+8LM91No2pqrSTuvWxyz3ABCdEFGH4ijklMNOP5qrs6TUvWxyz789abcDefgH12iJKlM3no4pQRs+5t6UVw7/xy+ZaBcdE+FGhIj8kLmnOpqrstuvw9xyzab1cD/ef23GhIjkLMNoPQrstuv=\n", 60 | }, 61 | // TODO: add test that would print SETs for windows 62 | } 63 | 64 | for _, test := range tests { 65 | t.Run(test.description, func(t *testing.T) { 66 | // defer func to handle panic in test 67 | defer func() { 68 | if test.want == "panic" { 69 | if r := recover(); r == nil { 70 | t.Errorf("function should panic") 71 | } 72 | } 73 | }() 74 | 75 | var output bytes.Buffer 76 | err := PrintSTAK(&output, test.stak, test.region) 77 | if err != nil { 78 | t.Error(err) 79 | } 80 | if test.want != "panic" && test.want != output.String() { 81 | t.Errorf("\ngot:\n %v\nwanted:\n %v", output.String(), test.want) 82 | } 83 | }) 84 | } 85 | } 86 | 87 | func TestPrintCredentialProcess(t *testing.T) { 88 | tests := []struct { 89 | description string 90 | stak kion.STAK 91 | }{ 92 | { 93 | "Partial STAK", 94 | kion.STAK{ 95 | AccessKey: "", 96 | SecretAccessKey: "aBCDeFg1hijkl2m3NOPqr4StUvWxY56z7abc8DEf", 97 | SessionToken: "", 98 | Duration: 43200, 99 | Expiration: time.Now().Add(43200 * time.Second), 100 | }, 101 | }, 102 | { 103 | "Full STAK", 104 | kion.STAK{ 105 | AccessKey: "ASIAABCDEFGHIJ1K23LM", 106 | SecretAccessKey: "aBCDeFg1hijkl2m3NOPqr4StUvWxY56z7abc8DEf", 107 | SessionToken: "AbcDEFghIJKlMNoPQrStuVwXYZabcDEfGhI1JklmNoPQRStu2VWXYZaBcd34ef+GH+IJKLmNOPQRSTU5VwxyzABcdeFGHIj6KlMNoPQ7rSTUvW8X9yZAbCD0ef+gHIJkLMnoPqrstUVwxyzAb1CD2e34fgHiJKlMnOPqr56STuvwXyzABcdEfgh7IJK+8LM91No2pqrSTuvWxyz3ABCdEFGH4ijklMNOP5qrs6TUvWxyz789abcDefgH12iJKlM3no4pQRs+5t6UVw7/xy+ZaBcdE+FGhIj8kLmnOpqrstuvw9xyzab1cD/ef23GhIjkLMNoPQrstuv=", 108 | Duration: 3600, 109 | Expiration: time.Now().Add(3600 * time.Second), 110 | }, 111 | }, 112 | // TODO: add a test that would cause the json marshaling to fail 113 | } 114 | 115 | for _, test := range tests { 116 | t.Run(test.description, func(t *testing.T) { 117 | var buf bytes.Buffer 118 | err := PrintCredentialProcess(&buf, test.stak) 119 | if err != nil { 120 | t.Fatalf("PrintCredentialProcess returned an error: %v", err) 121 | } 122 | 123 | // unmarshal the output into a map 124 | var output map[string]any 125 | err = json.Unmarshal(buf.Bytes(), &output) 126 | if err != nil { 127 | t.Fatalf("Failed to unmarshal the output: %v", err) 128 | } 129 | 130 | // parse the expiration field 131 | expiration, err := time.Parse(time.RFC3339, output["Expiration"].(string)) 132 | if err != nil { 133 | t.Fatalf("Failed to parse the Expiration field: %v", err) 134 | } 135 | 136 | // check if the expiration field is within a 1-second tolerance 137 | now := time.Now() 138 | duration := test.stak.Duration 139 | if duration == 0 { 140 | duration = 900 141 | } 142 | hardExpiration := now.Add(time.Duration(duration) * time.Second) 143 | if expiration.Before(hardExpiration.Add(-1*time.Second)) || expiration.After(hardExpiration.Add(1*time.Second)) { 144 | t.Fatalf("The 'Expiration' field is not within the expected range") 145 | } 146 | 147 | expected := fmt.Sprintf("{\n \"Version\": 1,\n \"AccessKeyId\": \"%v\",\n \"SecretAccessKey\": \"%v\",\n \"SessionToken\": \"%v\",\n \"Expiration\": \"%v\"\n}\n", test.stak.AccessKey, test.stak.SecretAccessKey, test.stak.SessionToken, output["Expiration"].(string)) 148 | 149 | if buf.String() != expected { 150 | t.Fatalf("Expected %s, but got %s", expected, buf.String()) 151 | } 152 | }) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /lib/helper/output.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "os/user" 11 | "path/filepath" 12 | "runtime" 13 | "strings" 14 | "time" 15 | 16 | "github.com/fatih/color" 17 | "github.com/kionsoftware/kion-cli/lib/kion" 18 | ) 19 | 20 | //////////////////////////////////////////////////////////////////////////////// 21 | // // 22 | // Output // 23 | // // 24 | //////////////////////////////////////////////////////////////////////////////// 25 | 26 | // PrintSTAK prints out the short term access keys for AWS auth. 27 | func PrintSTAK(w io.Writer, stak kion.STAK, region string) error { 28 | // handle windows vs linux for exports 29 | var export string 30 | if runtime.GOOS == "windows" { 31 | export = "SET" 32 | } else { 33 | export = "export" 34 | } 35 | 36 | // conditionally print region 37 | if region != "" { 38 | fmt.Fprintf(w, "%v AWS_REGION=%v\n", export, region) 39 | } 40 | 41 | // print the stak 42 | fmt.Fprintf(w, "%v AWS_ACCESS_KEY_ID=%v\nexport AWS_SECRET_ACCESS_KEY=%v\nexport AWS_SESSION_TOKEN=%v\n", export, stak.AccessKey, stak.SecretAccessKey, stak.SessionToken) 43 | 44 | return nil 45 | } 46 | 47 | // PrintFavoriteConfig prints out how to save the current selection as a 48 | // favorite within the users configuration file. 49 | func PrintFavoriteConfig(w io.Writer, car kion.CAR, region string, access_type string) error { 50 | color.New(color.FgBlue).Fprintf(w, "\nTo save your selection as a favorite add the following to\nyour configuration file under the 'favorites:' section:\n") 51 | fmt.Fprintf(w, " - name: ") 52 | color.New(color.FgGreen).Fprintf(w, "[your favorite alias]\n") 53 | fmt.Fprintf(w, " account: %v\n cloud_access_role: %v\n", car.AccountNumber, car.Name) 54 | if region != "" { 55 | fmt.Fprintf(w, " region: %v\n", region) 56 | } 57 | fmt.Fprintf(w, " access_type: %v\n\n", access_type) 58 | 59 | return nil 60 | } 61 | 62 | // PrintCredentialProcess prints out the short term access keys for use with 63 | // AWS profiles as a credential process subsystem. 64 | func PrintCredentialProcess(w io.Writer, stak kion.STAK) error { 65 | // create the credentials struct 66 | credentials := struct { 67 | Version int 68 | AccessKeyId string 69 | SecretAccessKey string 70 | SessionToken string 71 | Expiration string 72 | }{ 73 | 1, 74 | stak.AccessKey, 75 | stak.SecretAccessKey, 76 | stak.SessionToken, 77 | stak.Expiration.Format(time.RFC3339), 78 | } 79 | 80 | // marshal the credentials to json 81 | jsonData, err := json.MarshalIndent(credentials, "", " ") 82 | if err != nil { 83 | return err 84 | } 85 | 86 | // print the json data 87 | fmt.Fprintln(w, string(jsonData)) 88 | 89 | return nil 90 | } 91 | 92 | // SaveAWSCreds saves the short term access keys for AWS auth to the users AWS 93 | // configuration file. 94 | func SaveAWSCreds(stak kion.STAK, car kion.CAR) error { 95 | // get the current user home directory. 96 | user, err := user.Current() 97 | if err != nil { 98 | return err 99 | } 100 | 101 | // derive aws creds paths 102 | awsCredsDir := filepath.Join(user.HomeDir, ".aws") 103 | awsCredsFile := filepath.Join(user.HomeDir, ".aws/credentials") 104 | 105 | // if the folder or file does not exist, create them 106 | if _, err := os.Stat(awsCredsDir); os.IsNotExist(err) { 107 | // create directory 108 | errDir := os.MkdirAll(awsCredsDir, 0755) 109 | if errDir != nil { 110 | return err 111 | } 112 | } 113 | if _, err := os.Stat(awsCredsFile); os.IsNotExist(err) { 114 | err = os.WriteFile(awsCredsFile, []byte(""), 0600) 115 | if err != nil { 116 | return err 117 | } 118 | } 119 | 120 | // read in the creds file 121 | contents, err := os.ReadFile(awsCredsFile) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | // determine if the profile already exists 127 | profileName := fmt.Sprintf("[%v_%v]", car.AccountNumber, car.AwsIamRoleName) 128 | index := strings.Index(string(contents), profileName) 129 | 130 | // append the profile if it does not exist, else update it 131 | if index == -1 { 132 | f, err := os.OpenFile(awsCredsFile, os.O_APPEND|os.O_WRONLY, 0644) 133 | if err != nil { 134 | return err 135 | } 136 | 137 | linebreak := "\n" 138 | if runtime.GOOS == "windows" { 139 | linebreak = "\r\n" 140 | } 141 | 142 | text := "" 143 | text += fmt.Sprintf(linebreak+"[%v_%v]"+linebreak, car.AccountNumber, car.AwsIamRoleName) 144 | text += fmt.Sprintf("aws_access_key_id=%v"+linebreak, stak.AccessKey) 145 | text += fmt.Sprintf("aws_secret_access_key=%v"+linebreak, stak.SecretAccessKey) 146 | text += fmt.Sprintf("aws_session_token=%v"+linebreak, stak.SessionToken) 147 | 148 | _, err = f.WriteString(text) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | err = f.Close() 154 | if err != nil { 155 | return err 156 | } 157 | } else { 158 | f, err := os.Open(awsCredsFile) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | started := false 164 | 165 | buf := "" 166 | 167 | scanner := bufio.NewScanner(f) 168 | for scanner.Scan() { 169 | t := scanner.Text() 170 | if strings.Contains(t, profileName) { 171 | started = true 172 | buf += fmt.Sprintln(t) 173 | continue 174 | } 175 | 176 | if started { 177 | if !strings.Contains(t, "=") { 178 | started = false 179 | buf += fmt.Sprintln(t) 180 | continue 181 | } 182 | 183 | switch true { 184 | case strings.Contains(t, "aws_access_key_id"): 185 | buf += fmt.Sprintln("aws_access_key_id=" + stak.AccessKey) 186 | case strings.Contains(t, "aws_secret_access_key"): 187 | buf += fmt.Sprintln("aws_secret_access_key=" + stak.SecretAccessKey) 188 | case strings.Contains(t, "aws_session_token"): 189 | buf += fmt.Sprintln("aws_session_token=" + stak.SessionToken) 190 | default: 191 | return errors.New("there is a problem with the aws credentials file") 192 | } 193 | continue 194 | } 195 | buf += fmt.Sprintln(t) 196 | } 197 | 198 | if err := scanner.Err(); err != nil { 199 | return err 200 | } 201 | 202 | err = os.WriteFile(awsCredsFile, []byte(buf), 0600) 203 | if err != nil { 204 | return err 205 | } 206 | } 207 | 208 | fmt.Println("Credentials updated in the file:", awsCredsFile) 209 | fmt.Printf("You can reference this profile using this flag: --profile %v_%v\n", car.AccountNumber, car.AwsIamRoleName) 210 | fmt.Printf("Example command: aws s3 ls --profile %v_%v\n", car.AccountNumber, car.AwsIamRoleName) 211 | 212 | return nil 213 | } 214 | -------------------------------------------------------------------------------- /lib/kion/car.go: -------------------------------------------------------------------------------- 1 | package kion 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | //////////////////////////////////////////////////////////////////////////////// 11 | // // 12 | // Cloud Access Roles // 13 | // // 14 | //////////////////////////////////////////////////////////////////////////////// 15 | 16 | // CAR maps to the Kion API response for cloud access roles. 17 | type CAR struct { 18 | AccountID uint `json:"account_id"` 19 | AccountNumber string `json:"account_number"` 20 | AccountType string `json:"account_type"` 21 | AccountTypeID uint `json:"account_type_id"` 22 | AccountName string `json:"account_name"` 23 | AccountAlias string `json:"account_alias"` 24 | ApplyToAllAccounts bool `json:"apply_to_all_accounts"` 25 | AwsIamPath string `json:"aws_iam_path"` 26 | AwsIamRoleName string `json:"aws_iam_role_name"` 27 | CloudAccessRoleType string `json:"cloud_access_role_type"` 28 | CreatedAt struct { 29 | Time time.Time `json:"Time"` 30 | Valid bool `json:"Valid"` 31 | } `json:"created_at"` 32 | DeletedAt struct { 33 | Time time.Time `json:"Time"` 34 | Valid bool `json:"Valid"` 35 | } `json:"deleted_at"` 36 | FutureAccounts bool `json:"future_accounts"` 37 | ID uint `json:"id"` 38 | LongTermAccessKeys bool `json:"long_term_access_keys"` 39 | Name string `json:"name"` 40 | ProjectID uint `json:"project_id"` 41 | ShortTermAccessKeys bool `json:"short_term_access_keys"` 42 | UpdatedAt struct { 43 | Time time.Time `json:"Time"` 44 | Valid bool `json:"Valid"` 45 | } `json:"updated_at"` 46 | WebAccess bool `json:"web_access"` 47 | } 48 | 49 | // GetCARS queries the Kion API for all cloud access roles to which the 50 | // authenticated user has access. Deleted CARs will be excluded. 51 | func GetCARS(host string, token string, alias string) ([]CAR, error) { 52 | // build our query and get response 53 | url := fmt.Sprintf("%v/api/v3/me/cloud-access-role", host) 54 | query := map[string]string{ 55 | "account_alias": alias, 56 | } 57 | var data any 58 | resp, _, err := runQuery("GET", url, token, query, data) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | // unmarshal response body 64 | var allcars []CAR 65 | err = json.Unmarshal(resp.Data, &allcars) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | var cars []CAR 71 | for _, car := range allcars { 72 | if car.DeletedAt.Time.IsZero() { 73 | cars = append(cars, car) 74 | } 75 | } 76 | 77 | return cars, nil 78 | } 79 | 80 | // GetCARSOnProject returns all cloud access roles that match a given project and account. 81 | func GetCARSOnProject(host string, token string, projID uint, accID uint) ([]CAR, error) { 82 | allCars, err := GetCARS(host, token, "") 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | // reduce to cars that match project and account 88 | var cars []CAR 89 | for _, car := range allCars { 90 | if car.ProjectID == projID && car.AccountID == accID { 91 | cars = append(cars, car) 92 | } 93 | } 94 | 95 | return cars, nil 96 | } 97 | 98 | // GetCARSOnAccount returns all cloud access roles that match a given account. 99 | func GetCARSOnAccount(host string, token string, accID uint) ([]CAR, error) { 100 | allCars, err := GetCARS(host, token, "") 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | // reduce to cars that match project and account 106 | var cars []CAR 107 | for _, car := range allCars { 108 | if car.AccountID == accID { 109 | cars = append(cars, car) 110 | } 111 | } 112 | 113 | return cars, nil 114 | } 115 | 116 | // GetCARByName returns a car that matches a given name. IMPORTANT: please use 117 | // GetCARByNameAndAccount instead where possible as there are no constraints 118 | // against CARs with duplicate names, this function is kept as a convenience 119 | // and workaround for users on older version of Kion that have limited 120 | // permissions. 121 | func GetCARByName(host string, token string, carName string) (CAR, error) { 122 | allCars, err := GetCARS(host, token, "") 123 | if err != nil { 124 | return CAR{}, err 125 | } 126 | 127 | // find our match 128 | // TODO: build and return a slice of matching CARs, then on all references 129 | // should be updated to handle the slice and prompt users for selection or 130 | // test all for success silently 131 | for _, car := range allCars { 132 | if car.Name == carName { 133 | return car, nil 134 | } 135 | } 136 | 137 | return CAR{}, fmt.Errorf("unable to find car %v", carName) 138 | } 139 | 140 | // GetCARByNameAndAccount returns a car that matches by name and account number. 141 | func GetCARByNameAndAccount(host string, token string, carName string, accountNumber string) (CAR, error) { 142 | allCars, err := GetCARS(host, token, "") 143 | if err != nil { 144 | return CAR{}, err 145 | } 146 | 147 | // find our match 148 | for _, car := range allCars { 149 | if car.Name == carName && car.AccountNumber == accountNumber { 150 | return car, nil 151 | } 152 | } 153 | 154 | return CAR{}, fmt.Errorf("unable to find car %v with account number %v", carName, accountNumber) 155 | } 156 | 157 | // GetCARByNameAndAlias returns a car that matches by name and account alias. 158 | func GetCARByNameAndAlias(host string, token string, carName string, accountAlias string) (CAR, error) { 159 | allCars, err := GetCARS(host, token, accountAlias) 160 | if err != nil { 161 | return CAR{}, err 162 | } 163 | 164 | // find our match 165 | for _, car := range allCars { 166 | if car.Name == carName && strings.EqualFold(car.AccountAlias, accountAlias) { 167 | return car, nil 168 | } 169 | } 170 | 171 | return CAR{}, fmt.Errorf("unable to find car %v with account alias %v", carName, accountAlias) 172 | } 173 | 174 | // GetAllCARsByName returns a slice of cars that matches a given name. 175 | func GetAllCARsByName(host string, token string, carName string) ([]CAR, error) { 176 | allCars, err := GetCARS(host, token, "") 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | // find our matches 182 | var cars []CAR 183 | for _, car := range allCars { 184 | if car.Name == carName { 185 | account, _, err := GetAccount(host, token, car.AccountNumber) 186 | if err != nil { 187 | // TODO: this may not be what we want to do here, kept as info level log 188 | // fmt.Println(" unable to lookup an associated account:", car.AccountNumber) 189 | continue 190 | } 191 | car.AccountName = account.Name 192 | car.AccountTypeID = account.TypeID 193 | cars = append(cars, car) 194 | } 195 | } 196 | 197 | // return our slice of cars 198 | if len(cars) > 0 { 199 | return cars, nil 200 | } 201 | 202 | return nil, fmt.Errorf("unable to find car %v", carName) 203 | } 204 | -------------------------------------------------------------------------------- /lib/helper/wizards.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/kionsoftware/kion-cli/lib/kion" 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | //////////////////////////////////////////////////////////////////////////////// 11 | // // 12 | // Wizards // 13 | // // 14 | //////////////////////////////////////////////////////////////////////////////// 15 | 16 | // CARSelector is a wizard that walks a user through the selection of a 17 | // Project, then associated Accounts, then available Cloud Access Roles, to set 18 | // the user selected Cloud Access Role. Optional account number and or car name 19 | // can be passed via an existing car struct, the flow will dynamically ask what 20 | // is needed to be able to find the full car. 21 | func CARSelector(cCtx *cli.Context, car *kion.CAR) error { 22 | // get list of projects, then build list of names and lookup map 23 | projects, err := kion.GetProjects(cCtx.String("endpoint"), cCtx.String("token")) 24 | if err != nil { 25 | return err 26 | } 27 | pNames, pMap := MapProjects(projects) 28 | if len(pNames) == 0 { 29 | return fmt.Errorf("no projects found") 30 | } 31 | 32 | // prompt user to select a project 33 | project, err := PromptSelect("Choose a project:", pNames) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | if cCtx.App.Metadata["useUpdatedCloudAccessRoleAPI"] == true { 39 | // TODO: consolidate on this logic when support for 3.9 drops, that will 40 | // give us one full support line of buffer 41 | 42 | // get all cars for authed user, works with min permission set 43 | cars, err := kion.GetCARS(cCtx.String("endpoint"), cCtx.String("token"), "") 44 | if err != nil { 45 | return err 46 | } 47 | aNames, aMap := MapAccountsFromCARS(cars, pMap[project].ID) 48 | if len(aNames) == 0 { 49 | return fmt.Errorf("no accounts found") 50 | } 51 | 52 | // prompt user to select an account 53 | account, err := PromptSelect("Choose an Account:", aNames) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | // narrow it down to just cars associated with the account 59 | var carsFiltered []kion.CAR 60 | for _, carObj := range cars { 61 | if carObj.AccountNumber == aMap[account] { 62 | carsFiltered = append(carsFiltered, carObj) 63 | } 64 | } 65 | cNames, cMap := MapCAR(carsFiltered) 66 | if len(cNames) == 0 { 67 | return fmt.Errorf("you have no cloud access roles assigned") 68 | } 69 | 70 | // prompt user to select a car 71 | carname, err := PromptSelect("Choose a Cloud Access Role:", cNames) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | // inject the metadata into the car 77 | car.Name = cMap[carname].Name 78 | car.AccountName = cMap[carname].AccountName 79 | car.AccountNumber = aMap[account] 80 | car.AccountTypeID = cMap[carname].AccountTypeID 81 | car.AccountID = cMap[carname].AccountID 82 | car.AwsIamRoleName = cMap[carname].AwsIamRoleName 83 | car.ID = cMap[carname].ID 84 | car.CloudAccessRoleType = cMap[carname].CloudAccessRoleType 85 | 86 | // return nil 87 | return nil 88 | } else { 89 | // get list of accounts on project, then build a list of names and lookup map 90 | accounts, statusCode, err := kion.GetAccountsOnProject(cCtx.String("endpoint"), cCtx.String("token"), pMap[project].ID) 91 | if err != nil { 92 | if statusCode == 403 { 93 | // if we're getting a 403 work around permissions bug by temp using private api 94 | return carSelectorPrivateAPI(cCtx, pMap, project, car) 95 | } else { 96 | return err 97 | } 98 | } 99 | aNames, aMap := MapAccounts(accounts) 100 | if len(aNames) == 0 { 101 | return fmt.Errorf("no accounts found") 102 | } 103 | 104 | // prompt user to select an account 105 | account, err := PromptSelect("Choose an Account:", aNames) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | // get a list of cloud access roles, then build a list of names and lookup map 111 | cars, err := kion.GetCARSOnProject(cCtx.String("endpoint"), cCtx.String("token"), pMap[project].ID, aMap[account].ID) 112 | if err != nil { 113 | return err 114 | } 115 | cNames, cMap := MapCAR(cars) 116 | if len(cNames) == 0 { 117 | return fmt.Errorf("no cloud access roles found") 118 | } 119 | 120 | // prompt user to select a car 121 | carname, err := PromptSelect("Choose a Cloud Access Role:", cNames) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | // inject the metadata into the car 127 | car.Name = cMap[carname].Name 128 | car.AccountName = cMap[carname].AccountName 129 | car.AccountNumber = aMap[account].Number 130 | car.AccountTypeID = aMap[account].TypeID 131 | car.AccountID = aMap[account].ID 132 | car.AwsIamRoleName = cMap[carname].AwsIamRoleName 133 | car.ID = cMap[carname].ID 134 | car.CloudAccessRoleType = cMap[carname].CloudAccessRoleType 135 | 136 | // return nil 137 | return nil 138 | } 139 | } 140 | 141 | // carSelectorPrivateAPI is a temp shim workaround to address a public API 142 | // permissions issue. CARSelector should be called directly which will the 143 | // forward to this function if needed. 144 | func carSelectorPrivateAPI(cCtx *cli.Context, pMap map[string]kion.Project, project string, car *kion.CAR) error { 145 | // hit private api endpoint to gather all users cars and their associated accounts 146 | caCARs, err := kion.GetConsoleAccessCARS(cCtx.String("endpoint"), cCtx.String("token"), pMap[project].ID) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | // build a consolidated list of accounts from all available CARS and slice of cars per account 152 | var accounts []kion.Account 153 | cMap := make(map[string]kion.ConsoleAccessCAR) 154 | aToCMap := make(map[string][]string) 155 | for _, car := range caCARs { 156 | cname := fmt.Sprintf("%v (%v)", car.CARName, car.CARID) 157 | cMap[cname] = car 158 | for _, account := range car.Accounts { 159 | name := fmt.Sprintf("%v (%v)", account.Name, account.Number) 160 | aToCMap[name] = append(aToCMap[account.Name], cname) 161 | found := false 162 | for _, a := range accounts { 163 | if a.ID == account.ID { 164 | found = true 165 | } 166 | } 167 | if !found { 168 | accounts = append(accounts, account) 169 | } 170 | } 171 | } 172 | 173 | // build a list of names and lookup map 174 | aNames, aMap := MapAccounts(accounts) 175 | if len(aNames) == 0 { 176 | return fmt.Errorf("no accounts found") 177 | } 178 | 179 | // prompt user to select an account 180 | account, err := PromptSelect("Choose an Account:", aNames) 181 | if err != nil { 182 | return err 183 | } 184 | 185 | // prompt user to select car 186 | carname, err := PromptSelect("Choose a Cloud Access Role:", aToCMap[account]) 187 | if err != nil { 188 | return err 189 | } 190 | 191 | // build enough of a car and return it 192 | car.Name = cMap[carname].CARName 193 | car.AccountName = aMap[account].Name 194 | car.AccountNumber = aMap[account].Number 195 | car.AccountID = aMap[account].ID 196 | car.AwsIamRoleName = cMap[carname].AwsIamRoleName 197 | car.AccountTypeID = aMap[account].TypeID 198 | car.ID = cMap[carname].CARID 199 | car.CloudAccessRoleType = cMap[carname].CARRoleType 200 | 201 | return nil 202 | } 203 | -------------------------------------------------------------------------------- /doc/man1/kion.1: -------------------------------------------------------------------------------- 1 | .\"Modified from man(1) of FreeBSD, the NetBSD mdoc.template, and mdoc.samples. 2 | .\"See Also: 3 | .\"man mdoc.samples for a complete listing of options 4 | .\"man mdoc for the short list of editing options 5 | .\"/usr/share/misc/mdoc.template 6 | .Dd 8/4/10 \" DATE 7 | .Dt KION 1 \" Program name and manual section number 8 | .Os Darwin 9 | .Sh NAME \" Section Header - required - don't modify 10 | .Nm kion 11 | .Nd Kion on the command line. 12 | .Sh SYNOPSIS \" Section Header - required - don't modify 13 | .Nm kion 14 | .Op Ar global options \" [global options] 15 | .Op Ar command \" [command] 16 | .Op Ar command options \" [command options] 17 | .Op Ar arg \" [arg] 18 | 19 | .Sh DESCRIPTION 20 | The Kion CLI allows users to perform common Kion workflows via the command line. Users can quickly generate short-term access keys (stak) or federate into the cloud service provider web console via configured favorites or by walking through an account and role selection wizard. 21 | 22 | 23 | .Sh GLOBAL OPTIONS 24 | .Bl -tag -width "-cloud-access-role" 25 | .It --endpoint URL, -e URL, --url URL 26 | URL of the Kion instance to interface with. 27 | .It --user USER, -u USER, --username USER 28 | Username used for authenticating with Kion. 29 | .It --password PASSWORD, -p PASSWORD 30 | Password used for authenticating with Kion. 31 | .It --idms IDMS_ID, -i IDMS_ID 32 | IDMS ID with which to authenticate if using username and password. 33 | .It --saml-metadata-file FILENAME|URL 34 | FILENAME or URL of the identity provider's XML metadata document. 35 | .It --saml-sp-issuer ISSUER 36 | SAML Service Provider issuer value from Kion. 37 | .It --saml-print-url 38 | Print the authentication URL instead of opening it automatically with the default browser. 39 | .It --token TOKEN, -t TOKEN 40 | Token (API or Bearer) used to authenticate. 41 | .It --disable-cache 42 | Disable the use of cache for Kion CLI. 43 | .It --debug 44 | Enable debug mode for additional CLI output. 45 | .It --quiet 46 | Enable quiet mode for to reduce unnecessary output. 47 | .It --profile PROFILE 48 | Use the specified PROFILE from the Kion CLI configuration file. 49 | .It --help, -h 50 | Print usage text. 51 | .It --version, -v 52 | Print the Kion CLI version. 53 | .El 54 | 55 | .Sh COMMANDS 56 | .Bl -tag -width "-cloud-access-role" 57 | .It stak, s 58 | Generate short-term access keys. 59 | .Bl -tag -width "-cloud-access-role" 60 | .It --print, -p 61 | Print STAK only. 62 | .It --account val, --acc val, -a val 63 | Target account number, must be passed with --car. 64 | .It --alias val, --aka val, -l val 65 | Target account alias, must be passed with --car. 66 | .It --car val, --cloud-access-role val, -c val 67 | Target cloud access role, must be passed with --account or --alias. 68 | .It --region val, -r val 69 | Specify which region to target. 70 | .It --save, -s 71 | Save short-term keys to an AWS credentials profile. 72 | .It --credential-process 73 | Setup Kion CLI as a credentials process subsystem. 74 | .It --help, -h 75 | Print usage text. 76 | .El 77 | 78 | .It favorite, fav, f 79 | Access pre-configured favorites. 80 | .Bl -tag -width "-cloud-access-role" 81 | .It list 82 | List all configured favorites. 83 | .It --print, -p 84 | Print STAK only. 85 | .It --access-type val, -t val 86 | Override a favorites access type. Expects "cli" or "web". 87 | .It --web, -w 88 | Shortcut for `--access-type web`. 89 | .It --credential-process 90 | Setup Kion CLI as a credentials process subsystem. 91 | .It --help, -h 92 | Print usage text. 93 | .El 94 | 95 | .It console, con, c 96 | Federate into the cloud service provider console. 97 | .Bl -tag -width "-cloud-access-role" 98 | .It --account val, --acc val, -a val 99 | Target account number, must be passed with --car. 100 | .It --alias val, --aka val, -l val 101 | Target account alias, must be passed with --car. 102 | .It --car val, --cloud-access-role val, -c val 103 | Target cloud access role, must be passed with --account or --alias. 104 | .It --help, -h 105 | Print usage text. 106 | .El 107 | 108 | .It run 109 | Run a command with short-term access keys. 110 | .Bl -tag -width "-cloud-access-role" 111 | .It --favorite val, --fav val, -f val 112 | Specify which favorite to run against. 113 | .It --account val, -acc val, -a val 114 | Specify which account to target, must be passed with --car. 115 | .It --alias val, --aka val, -l val 116 | Target account alias, must be passed with --car. 117 | .It --car val, -c val 118 | Specify which Cloud Access Role to use, must be passed with --account or --alias. 119 | .It --region val, -r val 120 | Specify which region to target. 121 | .It --help, -h 122 | Print usage text. 123 | .El 124 | 125 | .It util 126 | Tools for managing Kion CLI. 127 | .Bl -tag -width "push-favorites" 128 | .It flush-cache 129 | Clear out all cache entries for the Kion CLI. 130 | .It push-favorites 131 | Push locally defined favorites up to Kion. This will overwrite any favorites in Kion that have the same name. After pushing, you are prompted to delete local favorites. 132 | .It validate-saml 133 | Validate SAML configuration. 134 | .El 135 | 136 | .Sh PRECEDENCE 137 | Configuration settings are applied in the following order of precedence: 138 | .Bl -enum 139 | .It Flags 140 | Command-line flags have the highest precedence and will override any other settings. 141 | .It Environment Variables 142 | Environment variables override settings in the configuration file and default values. 143 | .It Configuration File 144 | Settings specified in the configuration file override default values. 145 | .It Default Values 146 | Default values are used when no other settings are provided. 147 | .El 148 | 149 | .Sh ENVIRONMENT VARIABLES 150 | .Bl -tag -width "KION_SAML_SP_ISSUER" 151 | .It KION_CONFIG 152 | Path to the Kion CLI configuration file. Defaults to ~/.kion.yml. 153 | .It KION_URL 154 | URL of the Kion instance to interact with. 155 | .It KION_USERNAME 156 | Username used for authenticating with Kion. 157 | .It KION_PASSWORD 158 | Password used for authenticating with Kion. 159 | .It KION_IDMS_ID 160 | IDMS ID with which to authenticate if using username and password. 161 | .It KION_API_KEY 162 | API key used to authenticate. 163 | .It KION_SAML_METADATA_FILE 164 | FILENAME or URL of the identity provider's XML metadata document. 165 | .It KION_SAML_SP_ISSUER 166 | The Kion IDMS issuer value. 167 | .It KION_SAML_PRINT_URL 168 | "TRUE" to print the authentication url as opposed to automatically opening it in the default browser. Defaults to "FALSE". 169 | .It KION_DEBUG 170 | "TRUE" to enable verbose debugging of the Kion CLI. 171 | .It KION_QUIET 172 | "TRUE" to reduce messages for quieter operation. 173 | .El 174 | 175 | .Sh FILES 176 | .Bl -tag -width "~/.kion.yml" 177 | .It Pa ~/.kion.yml 178 | The user configuration file. Defines credentials, target Kion instance, and a list of favorites. 179 | .El 180 | 181 | .Sh EXAMPLES 182 | .Bl -tag -width "kion console --account 111122223333 --car Admin" 183 | .It kion fav sandbox 184 | Open the sandbox AWS console favorited in the config. 185 | .It kion stak --print --account 121212121212 --car Admin 186 | Generate and print keys for an AWS account. 187 | .It kion console --account 111122223333 --car Admin 188 | Federate into a web console using an account number. 189 | .El 190 | 191 | .Sh SEE ALSO 192 | .Xr kion 1 193 | .Xr aws 1 194 | 195 | .\" .Sh BUGS \" Document known, unremedied bugs 196 | .\" .Sh HISTORY \" Document history if command behaves in a unique manner 197 | -------------------------------------------------------------------------------- /lib/helper/helper_test.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "github.com/kionsoftware/kion-cli/lib/kion" 5 | "github.com/kionsoftware/kion-cli/lib/structs" 6 | ) 7 | 8 | //////////////////////////////////////////////////////////////////////////////// 9 | // // 10 | // Resources // 11 | // // 12 | //////////////////////////////////////////////////////////////////////////////// 13 | 14 | var kionTestProjects = []kion.Project{ 15 | {Archived: false, AutoPay: true, DefaultAwsRegion: "us-east-1", Description: "test description one", ID: 101, Name: "project one", OuID: 201}, 16 | {Archived: false, AutoPay: false, DefaultAwsRegion: "us-west-1", Description: "test description two", ID: 102, Name: "project two", OuID: 202}, 17 | {Archived: true, AutoPay: false, DefaultAwsRegion: "us-east-1", Description: "test description three", ID: 103, Name: "project three", OuID: 203}, 18 | {Archived: false, AutoPay: true, DefaultAwsRegion: "us-west-1", Description: "test description four", ID: 104, Name: "project four", OuID: 204}, 19 | {Archived: true, AutoPay: false, DefaultAwsRegion: "us-east-1", Description: "test description five", ID: 105, Name: "project five", OuID: 205}, 20 | {Archived: false, AutoPay: true, DefaultAwsRegion: "us-east-1", Description: "test description six", ID: 106, Name: "project six", OuID: 206}, 21 | } 22 | 23 | var kionTestProjectsNames = []string{ 24 | "project one", 25 | "project two", 26 | "project three", 27 | "project four", 28 | "project five", 29 | "project six", 30 | } 31 | 32 | var kionTestAccounts = []kion.Account{ 33 | {Email: "test1@kion.io", Name: "account one", Alias: "acct-one-alias", Number: "111111111111", TypeID: 1, ID: 101, IncludeLinkedAccountSpend: true, LinkedAccountNumber: "", LinkedRole: "", PayerID: 101, ProjectID: 101, SkipAccessChecking: true, UseOrgAccountInfo: false}, 34 | {Email: "test2@kion.io", Name: "account two", Alias: "acct-two-alias", Number: "121212121212", TypeID: 2, ID: 102, IncludeLinkedAccountSpend: false, LinkedAccountNumber: "", LinkedRole: "", PayerID: 102, ProjectID: 102, SkipAccessChecking: true, UseOrgAccountInfo: false}, 35 | {Email: "test3@kion.io", Name: "account three", Alias: "acct-three-alias", Number: "131313131313", TypeID: 3, ID: 103, IncludeLinkedAccountSpend: true, LinkedAccountNumber: "000000000000", LinkedRole: "", PayerID: 103, ProjectID: 103, SkipAccessChecking: true, UseOrgAccountInfo: false}, 36 | {Email: "test4@kion.io", Name: "account four", Alias: "acct-four-alias", Number: "141414141414", TypeID: 4, ID: 104, IncludeLinkedAccountSpend: false, LinkedAccountNumber: "", LinkedRole: "", PayerID: 104, ProjectID: 104, SkipAccessChecking: true, UseOrgAccountInfo: false}, 37 | {Email: "test5@kion.io", Name: "account five", Alias: "acct-five-alias", Number: "151515151515", TypeID: 5, ID: 105, IncludeLinkedAccountSpend: false, LinkedAccountNumber: "", LinkedRole: "", PayerID: 105, ProjectID: 105, SkipAccessChecking: true, UseOrgAccountInfo: false}, 38 | {Email: "test6@kion.io", Name: "account six", Alias: "acct-six-alias", Number: "161616161616", TypeID: 6, ID: 106, IncludeLinkedAccountSpend: false, LinkedAccountNumber: "", LinkedRole: "", PayerID: 106, ProjectID: 106, SkipAccessChecking: true, UseOrgAccountInfo: true}, 39 | } 40 | 41 | var kionTestAccountsNames = []string{ 42 | "account one", 43 | "account two", 44 | "account three", 45 | "account four", 46 | "account five", 47 | "account six", 48 | } 49 | 50 | var kionTestCARs = []kion.CAR{ 51 | {AccountID: 101, AccountNumber: "111111111111", AccountAlias: "acct-one-alias", AccountType: "aws", AccountTypeID: 1, AccountName: "account one", ApplyToAllAccounts: true, AwsIamPath: "some path", AwsIamRoleName: "role one", CloudAccessRoleType: "type", FutureAccounts: true, ID: 101, LongTermAccessKeys: false, Name: "car one", ProjectID: 101, ShortTermAccessKeys: true, WebAccess: true}, 52 | {AccountID: 102, AccountNumber: "121212121212", AccountAlias: "acct-two-alias", AccountType: "aws", AccountTypeID: 2, AccountName: "account two", ApplyToAllAccounts: true, AwsIamPath: "some path", AwsIamRoleName: "role two", CloudAccessRoleType: "type", FutureAccounts: true, ID: 102, LongTermAccessKeys: false, Name: "car two", ProjectID: 102, ShortTermAccessKeys: true, WebAccess: true}, 53 | {AccountID: 103, AccountNumber: "131313131313", AccountAlias: "acct-three-alias", AccountType: "aws", AccountTypeID: 3, AccountName: "account three", ApplyToAllAccounts: true, AwsIamPath: "some path", AwsIamRoleName: "role three", CloudAccessRoleType: "type", FutureAccounts: true, ID: 103, LongTermAccessKeys: false, Name: "car three", ProjectID: 103, ShortTermAccessKeys: true, WebAccess: true}, 54 | {AccountID: 104, AccountNumber: "141414141414", AccountAlias: "acct-four-alias", AccountType: "aws", AccountTypeID: 4, AccountName: "account four", ApplyToAllAccounts: true, AwsIamPath: "some path", AwsIamRoleName: "role four", CloudAccessRoleType: "type", FutureAccounts: true, ID: 104, LongTermAccessKeys: false, Name: "car four", ProjectID: 104, ShortTermAccessKeys: true, WebAccess: true}, 55 | {AccountID: 105, AccountNumber: "151515151515", AccountAlias: "acct-five-alias", AccountType: "aws", AccountTypeID: 5, AccountName: "account five", ApplyToAllAccounts: true, AwsIamPath: "some path", AwsIamRoleName: "role five", CloudAccessRoleType: "type", FutureAccounts: true, ID: 105, LongTermAccessKeys: false, Name: "car five", ProjectID: 105, ShortTermAccessKeys: true, WebAccess: true}, 56 | {AccountID: 106, AccountNumber: "161616161616", AccountAlias: "acct-six-alias", AccountType: "aws", AccountTypeID: 6, AccountName: "account six", ApplyToAllAccounts: true, AwsIamPath: "some path", AwsIamRoleName: "role six", CloudAccessRoleType: "type", FutureAccounts: true, ID: 106, LongTermAccessKeys: false, Name: "car six", ProjectID: 106, ShortTermAccessKeys: true, WebAccess: true}, 57 | } 58 | 59 | var kionTestCARsNames = []string{ 60 | "car one", 61 | "car two", 62 | "car three", 63 | "car four", 64 | "car five", 65 | "car six", 66 | } 67 | 68 | var kionTestIDMSs = []kion.IDMS{ 69 | {ID: 101, IdmsTypeID: 1, Name: "idms one"}, 70 | {ID: 102, IdmsTypeID: 2, Name: "idms two"}, 71 | {ID: 103, IdmsTypeID: 3, Name: "idms three"}, 72 | {ID: 104, IdmsTypeID: 4, Name: "idms four"}, 73 | {ID: 105, IdmsTypeID: 5, Name: "idms five"}, 74 | {ID: 106, IdmsTypeID: 6, Name: "idms six"}, 75 | } 76 | 77 | var kionTestIDMSsNames = []string{ 78 | "idms one", 79 | "idms two", 80 | "idms three", 81 | "idms four", 82 | "idms five", 83 | "idms six", 84 | } 85 | 86 | var kionTestFavorites = []structs.Favorite{ 87 | { 88 | Name: "fav one", 89 | Account: "111111111111", 90 | CAR: "car one", 91 | AccessType: "web", 92 | DescriptiveName: "fav one [local] (111111111111 car one web)", 93 | }, 94 | { 95 | Name: "fav two", 96 | Account: "121212121212", 97 | CAR: "car two", 98 | AccessType: "web", 99 | DescriptiveName: "fav two [local] (121212121212 car two web)", 100 | }, 101 | { 102 | Name: "fav three", 103 | Account: "131313131313", 104 | CAR: "car three", 105 | AccessType: "web", 106 | DescriptiveName: "fav three [local] (131313131313 car three web)", 107 | }, 108 | { 109 | Name: "fav four", 110 | Account: "141414141414", 111 | CAR: "car four", 112 | AccessType: "web", 113 | DescriptiveName: "fav four [local] (141414141414 car four web)", 114 | }, 115 | { 116 | Name: "fav five", 117 | Account: "151515151515", 118 | CAR: "car five", 119 | AccessType: "web", 120 | DescriptiveName: "fav five [local] (151515151515 car five web)", 121 | }, 122 | { 123 | Name: "fav six", 124 | Account: "161616161616", 125 | CAR: "car six", 126 | AccessType: "web", 127 | DescriptiveName: "fav six [local] (161616161616 car six web)", 128 | }, 129 | } 130 | 131 | //////////////////////////////////////////////////////////////////////////////// 132 | // // 133 | // Helpers // 134 | // // 135 | //////////////////////////////////////////////////////////////////////////////// 136 | -------------------------------------------------------------------------------- /lib/commands/authentication.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/fatih/color" 9 | "github.com/kionsoftware/kion-cli/lib/helper" 10 | "github.com/kionsoftware/kion-cli/lib/kion" 11 | samlTypes "github.com/russellhaering/gosaml2/types" 12 | "github.com/urfave/cli/v2" 13 | ) 14 | 15 | // authUNPW prompts for any missing credentials then auths the users against 16 | // Kion, stores the session data, and sets the context token. 17 | func (c *Cmd) authUNPW(cCtx *cli.Context) error { 18 | var err error 19 | un := c.config.Kion.Username 20 | pw := c.config.Kion.Password 21 | idmsID := cCtx.Uint("idms") 22 | 23 | // prompt idms if needed 24 | if idmsID == 0 { 25 | idmss, err := kion.GetIDMSs(c.config.Kion.URL) 26 | if err != nil { 27 | return err 28 | } 29 | iNames, iMap := helper.MapIDMSs(idmss) 30 | if len(iNames) > 1 { 31 | idms, err := helper.PromptSelect("Select Login IDMS:", iNames) 32 | if err != nil { 33 | return err 34 | } 35 | idmsID = iMap[idms].ID 36 | } else { 37 | idmsID = iMap[iNames[0]].ID 38 | } 39 | } 40 | 41 | // prompt username if needed 42 | if un == "" { 43 | un, err = helper.PromptInput("Username:") 44 | if err != nil { 45 | return err 46 | } 47 | } 48 | 49 | // prompt password if needed 50 | pwFoundInCache := false 51 | if pw == "" { 52 | // Check password cache 53 | pw, pwFoundInCache, err = c.cache.GetPassword(c.config.Kion.URL, idmsID, un) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if !pwFoundInCache { 59 | pw, err = helper.PromptPassword("Password:") 60 | if err != nil { 61 | return err 62 | } 63 | } 64 | } 65 | 66 | // auth and capture our session 67 | session, err := kion.Authenticate(c.config.Kion.URL, idmsID, un, pw) 68 | if err != nil { 69 | // Unfortunately, the remote auth endpoint doesn't provide an easy way 70 | // of determining if an auth error was the cause of failure (it returns 71 | // an HTTP 400 with a body that contains a message about authentication 72 | // failues). Conservatively clear out any cached password when 73 | // Authenticate() fails 74 | if pwFoundInCache { 75 | err := c.cache.SetPassword(c.config.Kion.URL, idmsID, un, "") 76 | if err != nil { 77 | // We're already handling another error, logging 78 | // is the best we can do 79 | color.Red("Failed to clear password from cache, %v", err) 80 | } 81 | } 82 | return err 83 | } 84 | session.IDMSID = idmsID 85 | session.UserName = un 86 | err = c.cache.SetSession(session) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | // if auth succeeded, cache the password 92 | err = c.cache.SetPassword(c.config.Kion.URL, idmsID, un, pw) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | // set our token in the config 98 | c.config.Kion.APIKey = session.Access.Token 99 | return nil 100 | } 101 | 102 | // authSAML directs the user to authenticate via SAML in a web browser. 103 | // The SAML assertion is posted to this app which is forwarded to Kion and 104 | // exchanged for the context token. 105 | func (c *Cmd) authSAML(cCtx *cli.Context) error { 106 | var err error 107 | samlMetadataFile := c.config.Kion.SamlMetadataFile 108 | samlServiceProviderIssuer := c.config.Kion.SamlIssuer 109 | 110 | // Validate Kion URL is configured 111 | if c.config.Kion.URL == "" { 112 | return fmt.Errorf("the Kion URL is not configured; please set 'url' in your configuration file or use the --url flag") 113 | } 114 | 115 | // prompt metadata url if needed 116 | if samlMetadataFile == "" { 117 | samlMetadataFile, err = helper.PromptInput("SAML Metadata URL or File Path:") 118 | if err != nil { 119 | return err 120 | } 121 | if samlMetadataFile == "" { 122 | return fmt.Errorf("SAML Metadata URL/File is required for SAML authentication") 123 | } 124 | } 125 | 126 | // prompt issuer if needed 127 | if samlServiceProviderIssuer == "" { 128 | samlServiceProviderIssuer, err = helper.PromptInput("SAML Service Provider Issuer:") 129 | if err != nil { 130 | return err 131 | } 132 | if samlServiceProviderIssuer == "" { 133 | return fmt.Errorf("SAML Service Provider Issuer is required for SAML authentication") 134 | } 135 | } 136 | 137 | var samlMetadata *samlTypes.EntityDescriptor 138 | if strings.HasPrefix(samlMetadataFile, "http") { 139 | samlMetadata, err = kion.DownloadSAMLMetadata(samlMetadataFile) 140 | if err != nil { 141 | return fmt.Errorf("failed to download SAML metadata: %w", err) 142 | } 143 | } else { 144 | samlMetadata, err = kion.ReadSAMLMetadataFile(samlMetadataFile) 145 | if err != nil { 146 | return fmt.Errorf("failed to read SAML metadata file: %w", err) 147 | } 148 | } 149 | 150 | var authData *kion.AuthData 151 | 152 | // we only need to check for existence - the value is irrelevant 153 | if cCtx.App.Metadata["useOldSAML"] == true { 154 | authData, err = kion.AuthenticateSAMLOld( 155 | c.config.Kion.URL, 156 | samlMetadata, 157 | samlServiceProviderIssuer, 158 | c.config.Kion.SamlPrintURL, 159 | ) 160 | if err != nil { 161 | return err 162 | } 163 | } else { 164 | authData, err = kion.AuthenticateSAML( 165 | c.config.Kion.URL, 166 | samlMetadata, 167 | samlServiceProviderIssuer, 168 | c.config.Kion.SamlPrintURL, 169 | ) 170 | if err != nil { 171 | return err 172 | } 173 | } 174 | 175 | // cache the session for 9.5 minutes, tokens are valid for 10 minutes 176 | timeFormat := "2006-01-02T15:04:05-0700" 177 | session := kion.Session{ 178 | Access: struct { 179 | Expiry string `json:"expiry"` 180 | Token string `json:"token"` 181 | }{ 182 | Token: authData.AuthToken, 183 | Expiry: time.Now().Add(570 * time.Second).Format(timeFormat), 184 | }, 185 | } 186 | err = c.cache.SetSession(session) 187 | if err != nil { 188 | return err 189 | } 190 | 191 | // set our token in the config 192 | c.config.Kion.APIKey = authData.AuthToken 193 | return nil 194 | } 195 | 196 | // setAuthToken sets the token to be used for querying the Kion API. If not 197 | // passed to the tool as an argument, set in the env, or present in the 198 | // configuration dotfile it will prompt the users to authenticate. Auth methods 199 | // are prioritized as follows: api/bearer token -> username/password -> saml. 200 | // If flags are set for multiple methods the highest priority method will be 201 | // used. 202 | func (c *Cmd) setAuthToken(cCtx *cli.Context) error { 203 | if c.config.Kion.APIKey == "" { 204 | // if we still have an active session use it 205 | session, found, err := c.cache.GetSession() 206 | if err != nil { 207 | return err 208 | } 209 | if found && session.Access.Expiry != "" { 210 | timeFormat := "2006-01-02T15:04:05-0700" 211 | now := time.Now() 212 | expiration, err := time.Parse(timeFormat, session.Access.Expiry) 213 | if err != nil { 214 | return err 215 | } 216 | if expiration.After(now) { 217 | // TODO: test token is good with an endpoint that is accessible to all 218 | // user permission levels, if you get a 401 then assume token is bad 219 | // due to caching a cred when a users password expired, and flush the 220 | // cache instead... 221 | c.config.Kion.APIKey = session.Access.Token 222 | return nil 223 | } 224 | 225 | // TODO: uncomment when / if the application supports refresh tokens 226 | 227 | // // see if we can use the refresh token 228 | // refreshExp, err := time.Parse(timeFormat, session.Refresh.Expiry) 229 | // if err != nil { 230 | // return err 231 | // } 232 | 233 | // if refreshExp.After(now) { 234 | // un := session.UserName 235 | // idmsId := session.IDMSID 236 | // session, err = kion.Authenticate(c.config.Kion.Url, idmsId, un, session.Refresh.Token) 237 | // if err != nil { 238 | // return err 239 | // } 240 | // session.UserName = un 241 | // session.IDMSID = idmsId 242 | // err = c.cache.SetSession(session) 243 | // if err != nil { 244 | // return err 245 | // } 246 | 247 | // c.config.Kion.ApiKey = session.Access.Token 248 | // return nil 249 | // } 250 | } 251 | 252 | // check un / pw were set via flags and infer auth method 253 | if c.config.Kion.Username != "" || c.config.Kion.Password != "" { 254 | err := c.authUNPW(cCtx) 255 | return err 256 | } 257 | 258 | // check if saml auth flags set and auth with saml if so 259 | if c.config.Kion.SamlMetadataFile != "" && c.config.Kion.SamlIssuer != "" { 260 | err := c.authSAML(cCtx) 261 | return err 262 | } 263 | 264 | // if no token or session found, prompt for desired auth method 265 | methods := []string{ 266 | "API Key", 267 | "Password", 268 | "SAML", 269 | } 270 | authMethod, err := helper.PromptSelect("How would you like to authenticate", methods) 271 | if err != nil { 272 | return err 273 | } 274 | 275 | // handle chosen auth method 276 | switch authMethod { 277 | case "API Key": 278 | apiKey, err := helper.PromptPassword("API Key:") 279 | if err != nil { 280 | return err 281 | } 282 | c.config.Kion.APIKey = apiKey 283 | case "Password": 284 | err := c.authUNPW(cCtx) 285 | if err != nil { 286 | return err 287 | } 288 | case "SAML": 289 | err := c.authSAML(cCtx) 290 | if err != nil { 291 | return err 292 | } 293 | } 294 | } 295 | return nil 296 | } 297 | -------------------------------------------------------------------------------- /lib/helper/transform_test.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/kionsoftware/kion-cli/lib/kion" 9 | "github.com/kionsoftware/kion-cli/lib/structs" 10 | ) 11 | 12 | func TestMapProjects(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | projects []kion.Project 16 | wantOne []string 17 | wantTwo map[string]kion.Project 18 | }{ 19 | { 20 | "Basic", 21 | kionTestProjects, 22 | []string{ 23 | fmt.Sprintf("%v (%v)", kionTestProjectsNames[4], kionTestProjects[4].ID), 24 | fmt.Sprintf("%v (%v)", kionTestProjectsNames[3], kionTestProjects[3].ID), 25 | fmt.Sprintf("%v (%v)", kionTestProjectsNames[0], kionTestProjects[0].ID), 26 | fmt.Sprintf("%v (%v)", kionTestProjectsNames[5], kionTestProjects[5].ID), 27 | fmt.Sprintf("%v (%v)", kionTestProjectsNames[2], kionTestProjects[2].ID), 28 | fmt.Sprintf("%v (%v)", kionTestProjectsNames[1], kionTestProjects[1].ID), 29 | }, 30 | map[string]kion.Project{ 31 | fmt.Sprintf("%v (%v)", kionTestProjectsNames[0], kionTestProjects[0].ID): kionTestProjects[0], 32 | fmt.Sprintf("%v (%v)", kionTestProjectsNames[1], kionTestProjects[1].ID): kionTestProjects[1], 33 | fmt.Sprintf("%v (%v)", kionTestProjectsNames[2], kionTestProjects[2].ID): kionTestProjects[2], 34 | fmt.Sprintf("%v (%v)", kionTestProjectsNames[3], kionTestProjects[3].ID): kionTestProjects[3], 35 | fmt.Sprintf("%v (%v)", kionTestProjectsNames[4], kionTestProjects[4].ID): kionTestProjects[4], 36 | fmt.Sprintf("%v (%v)", kionTestProjectsNames[5], kionTestProjects[5].ID): kionTestProjects[5], 37 | }, 38 | }, 39 | } 40 | 41 | for _, test := range tests { 42 | t.Run(test.name, func(t *testing.T) { 43 | one, two := MapProjects(test.projects) 44 | if !reflect.DeepEqual(test.wantOne, one) || !reflect.DeepEqual(test.wantTwo, two) { 45 | t.Errorf("\ngot:\n %v\n %v\nwanted:\n %v\n %v", one, two, test.wantOne, test.wantTwo) 46 | } 47 | }) 48 | } 49 | } 50 | 51 | func TestMapAccounts(t *testing.T) { 52 | tests := []struct { 53 | name string 54 | accounts []kion.Account 55 | wantOne []string 56 | wantTwo map[string]kion.Account 57 | }{ 58 | { 59 | "Basic", 60 | kionTestAccounts, 61 | []string{ 62 | fmt.Sprintf("%v [%v] (%v)", kionTestAccountsNames[4], kionTestAccounts[4].Alias, kionTestAccounts[4].Number), 63 | fmt.Sprintf("%v [%v] (%v)", kionTestAccountsNames[3], kionTestAccounts[3].Alias, kionTestAccounts[3].Number), 64 | fmt.Sprintf("%v [%v] (%v)", kionTestAccountsNames[0], kionTestAccounts[0].Alias, kionTestAccounts[0].Number), 65 | fmt.Sprintf("%v [%v] (%v)", kionTestAccountsNames[5], kionTestAccounts[5].Alias, kionTestAccounts[5].Number), 66 | fmt.Sprintf("%v [%v] (%v)", kionTestAccountsNames[2], kionTestAccounts[2].Alias, kionTestAccounts[2].Number), 67 | fmt.Sprintf("%v [%v] (%v)", kionTestAccountsNames[1], kionTestAccounts[1].Alias, kionTestAccounts[1].Number), 68 | }, 69 | map[string]kion.Account{ 70 | fmt.Sprintf("%v [%v] (%v)", kionTestAccountsNames[0], kionTestAccounts[0].Alias, kionTestAccounts[0].Number): kionTestAccounts[0], 71 | fmt.Sprintf("%v [%v] (%v)", kionTestAccountsNames[1], kionTestAccounts[1].Alias, kionTestAccounts[1].Number): kionTestAccounts[1], 72 | fmt.Sprintf("%v [%v] (%v)", kionTestAccountsNames[2], kionTestAccounts[2].Alias, kionTestAccounts[2].Number): kionTestAccounts[2], 73 | fmt.Sprintf("%v [%v] (%v)", kionTestAccountsNames[3], kionTestAccounts[3].Alias, kionTestAccounts[3].Number): kionTestAccounts[3], 74 | fmt.Sprintf("%v [%v] (%v)", kionTestAccountsNames[4], kionTestAccounts[4].Alias, kionTestAccounts[4].Number): kionTestAccounts[4], 75 | fmt.Sprintf("%v [%v] (%v)", kionTestAccountsNames[5], kionTestAccounts[5].Alias, kionTestAccounts[5].Number): kionTestAccounts[5], 76 | }, 77 | }, 78 | } 79 | 80 | for _, test := range tests { 81 | t.Run(test.name, func(t *testing.T) { 82 | one, two := MapAccounts(test.accounts) 83 | if !reflect.DeepEqual(test.wantOne, one) || !reflect.DeepEqual(test.wantTwo, two) { 84 | t.Errorf("\ngot:\n %v\n %v\nwanted:\n %v\n %v", one, two, test.wantOne, test.wantTwo) 85 | } 86 | }) 87 | } 88 | } 89 | func TestMapCAR(t *testing.T) { 90 | tests := []struct { 91 | name string 92 | cars []kion.CAR 93 | wantOne []string 94 | wantTwo map[string]kion.CAR 95 | }{ 96 | { 97 | "Basic", 98 | kionTestCARs, 99 | []string{ 100 | fmt.Sprintf("%v (%v)", kionTestCARsNames[4], kionTestCARs[4].ID), 101 | fmt.Sprintf("%v (%v)", kionTestCARsNames[3], kionTestCARs[3].ID), 102 | fmt.Sprintf("%v (%v)", kionTestCARsNames[0], kionTestCARs[0].ID), 103 | fmt.Sprintf("%v (%v)", kionTestCARsNames[5], kionTestCARs[5].ID), 104 | fmt.Sprintf("%v (%v)", kionTestCARsNames[2], kionTestCARs[2].ID), 105 | fmt.Sprintf("%v (%v)", kionTestCARsNames[1], kionTestCARs[1].ID), 106 | }, 107 | map[string]kion.CAR{ 108 | fmt.Sprintf("%v (%v)", kionTestCARsNames[0], kionTestCARs[0].ID): kionTestCARs[0], 109 | fmt.Sprintf("%v (%v)", kionTestCARsNames[1], kionTestCARs[1].ID): kionTestCARs[1], 110 | fmt.Sprintf("%v (%v)", kionTestCARsNames[2], kionTestCARs[2].ID): kionTestCARs[2], 111 | fmt.Sprintf("%v (%v)", kionTestCARsNames[3], kionTestCARs[3].ID): kionTestCARs[3], 112 | fmt.Sprintf("%v (%v)", kionTestCARsNames[4], kionTestCARs[4].ID): kionTestCARs[4], 113 | fmt.Sprintf("%v (%v)", kionTestCARsNames[5], kionTestCARs[5].ID): kionTestCARs[5], 114 | }, 115 | }, 116 | } 117 | 118 | for _, test := range tests { 119 | t.Run(test.name, func(t *testing.T) { 120 | one, two := MapCAR(test.cars) 121 | if !reflect.DeepEqual(test.wantOne, one) || !reflect.DeepEqual(test.wantTwo, two) { 122 | t.Errorf("\ngot:\n %v\n %v\nwanted:\n %v\n %v", one, two, test.wantOne, test.wantTwo) 123 | } 124 | }) 125 | } 126 | } 127 | 128 | func TestMapIDMSs(t *testing.T) { 129 | tests := []struct { 130 | name string 131 | idmss []kion.IDMS 132 | wantOne []string 133 | wantTwo map[string]kion.IDMS 134 | }{ 135 | { 136 | "Basic", 137 | kionTestIDMSs, 138 | []string{ 139 | kionTestIDMSsNames[4], 140 | kionTestIDMSsNames[3], 141 | kionTestIDMSsNames[0], 142 | kionTestIDMSsNames[5], 143 | kionTestIDMSsNames[2], 144 | kionTestIDMSsNames[1], 145 | }, 146 | map[string]kion.IDMS{ 147 | kionTestIDMSsNames[0]: kionTestIDMSs[0], 148 | kionTestIDMSsNames[1]: kionTestIDMSs[1], 149 | kionTestIDMSsNames[2]: kionTestIDMSs[2], 150 | kionTestIDMSsNames[3]: kionTestIDMSs[3], 151 | kionTestIDMSsNames[4]: kionTestIDMSs[4], 152 | kionTestIDMSsNames[5]: kionTestIDMSs[5], 153 | }, 154 | }, 155 | } 156 | 157 | for _, test := range tests { 158 | t.Run(test.name, func(t *testing.T) { 159 | one, two := MapIDMSs(test.idmss) 160 | // if !reflect.DeepEqual(test.wantOne, one) || !reflect.DeepEqual(test.wantTwo, two) { 161 | if !reflect.DeepEqual(test.wantOne, one) || !reflect.DeepEqual(test.wantTwo, two) { 162 | t.Errorf("\ngot:\n %v\n %v\nwanted:\n %v\n %v", one, two, test.wantOne, test.wantTwo) 163 | } 164 | }) 165 | } 166 | } 167 | 168 | func TestMapFavs(t *testing.T) { 169 | tests := []struct { 170 | name string 171 | favorites []structs.Favorite 172 | wantOne []string 173 | wantTwo map[string]structs.Favorite 174 | }{ 175 | { 176 | "Basic", 177 | kionTestFavorites, 178 | []string{ 179 | "fav five [local] (151515151515 car five web)", 180 | "fav four [local] (141414141414 car four web)", 181 | "fav one [local] (111111111111 car one web)", 182 | "fav six [local] (161616161616 car six web)", 183 | "fav three [local] (131313131313 car three web)", 184 | "fav two [local] (121212121212 car two web)", 185 | }, 186 | map[string]structs.Favorite{ 187 | "fav one [local] (111111111111 car one web)": kionTestFavorites[0], 188 | "fav two [local] (121212121212 car two web)": kionTestFavorites[1], 189 | "fav three [local] (131313131313 car three web)": kionTestFavorites[2], 190 | "fav four [local] (141414141414 car four web)": kionTestFavorites[3], 191 | "fav five [local] (151515151515 car five web)": kionTestFavorites[4], 192 | "fav six [local] (161616161616 car six web)": kionTestFavorites[5], 193 | }, 194 | }, 195 | } 196 | 197 | for _, test := range tests { 198 | t.Run(test.name, func(t *testing.T) { 199 | one, two := MapFavs(test.favorites) 200 | if !reflect.DeepEqual(test.wantOne, one) || !reflect.DeepEqual(test.wantTwo, two) { 201 | t.Errorf("\ngot:\n %v\n %v\nwanted:\n %v\n %v", one, two, test.wantOne, test.wantTwo) 202 | } 203 | }) 204 | } 205 | } 206 | 207 | func TestFindCARByName(t *testing.T) { 208 | tests := []struct { 209 | name string 210 | find string 211 | cars []kion.CAR 212 | wantCAR kion.CAR 213 | wantErr error 214 | }{ 215 | { 216 | "Find Match", 217 | "car one", 218 | kionTestCARs, 219 | kionTestCARs[0], 220 | nil, 221 | }, 222 | { 223 | "Find No Match", 224 | "fake car", 225 | kionTestCARs, 226 | kion.CAR{}, 227 | fmt.Errorf("cannot find cloud access role with name %v", "fake car"), 228 | }, 229 | } 230 | 231 | for _, test := range tests { 232 | t.Run(test.name, func(t *testing.T) { 233 | car, err := FindCARByName(test.cars, test.find) 234 | // if !reflect.DeepEqual(&test.wantCAR, car) || test.wantErr != err { 235 | if !reflect.DeepEqual(&test.wantCAR, car) || !reflect.DeepEqual(test.wantErr, err) { 236 | t.Errorf("\ngot:\n %v\n %v\nwanted:\n %v\n %v", car, err, &test.wantCAR, test.wantErr) 237 | } 238 | }) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /lib/helper/transform.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "sort" 7 | 8 | "github.com/kionsoftware/kion-cli/lib/kion" 9 | "github.com/kionsoftware/kion-cli/lib/structs" 10 | 11 | "github.com/fatih/color" 12 | ) 13 | 14 | func padName(name string) string { 15 | nameLen := len(name) 16 | padding := fmt.Sprintf("%*s", max(12-nameLen, 0), "") 17 | return fmt.Sprintf("%s%s", name, padding) 18 | } 19 | 20 | //////////////////////////////////////////////////////////////////////////////// 21 | // // 22 | // Transform // 23 | // // 24 | //////////////////////////////////////////////////////////////////////////////// 25 | 26 | // MapProjects transforms a slice of Projects into a slice of their names and a 27 | // map indexed by their names. 28 | func MapProjects(projects []kion.Project) ([]string, map[string]kion.Project) { 29 | var pNames []string 30 | pMap := make(map[string]kion.Project) 31 | for _, project := range projects { 32 | name := fmt.Sprintf("%v (%v)", project.Name, project.ID) 33 | pNames = append(pNames, name) 34 | pMap[name] = project 35 | } 36 | sort.Strings(pNames) 37 | 38 | return pNames, pMap 39 | } 40 | 41 | // MapAccounts transforms a slice of Accounts into a slice of their names and a 42 | // map indexed by their names. 43 | func MapAccounts(accounts []kion.Account) ([]string, map[string]kion.Account) { 44 | var aNames []string 45 | aMap := make(map[string]kion.Account) 46 | for _, account := range accounts { 47 | var name string 48 | if account.Alias != "" { 49 | name = fmt.Sprintf("%v [%v] (%v)", account.Name, account.Alias, account.Number) 50 | } else { 51 | name = fmt.Sprintf("%v (%v)", account.Name, account.Number) 52 | } 53 | aNames = append(aNames, name) 54 | aMap[name] = account 55 | } 56 | sort.Strings(aNames) 57 | 58 | return aNames, aMap 59 | } 60 | 61 | // MapAccountsFromCARS transforms a slice of CARs into a slice of account names 62 | // and a map of account numbers indexed by their names. If a project ID is 63 | // passed it will only return accounts in the given project. Note that some 64 | // versions of Kion will not populate account metadata in CAR objects so use 65 | // carefully (see useUpdatedCloudAccessRoleAPI bool). 66 | func MapAccountsFromCARS(cars []kion.CAR, pid uint) ([]string, map[string]string) { 67 | var aNames []string 68 | aMap := make(map[string]string) 69 | for _, car := range cars { 70 | if pid == 0 || car.ProjectID == pid { 71 | var name string 72 | if car.AccountAlias != "" { 73 | name = fmt.Sprintf("%v [%v] (%v)", car.AccountName, car.AccountAlias, car.AccountNumber) 74 | } else { 75 | name = fmt.Sprintf("%v (%v)", car.AccountName, car.AccountNumber) 76 | } 77 | if slices.Contains(aNames, name) { 78 | continue 79 | } 80 | aNames = append(aNames, name) 81 | aMap[name] = car.AccountNumber 82 | } 83 | } 84 | sort.Strings(aNames) 85 | 86 | return aNames, aMap 87 | } 88 | 89 | // MapCAR transforms a slice of CARs into a slice of their names and a map 90 | // indexed by their names. 91 | func MapCAR(cars []kion.CAR) ([]string, map[string]kion.CAR) { 92 | var cNames []string 93 | cMap := make(map[string]kion.CAR) 94 | for _, car := range cars { 95 | name := fmt.Sprintf("%v (%v)", car.Name, car.ID) 96 | cNames = append(cNames, name) 97 | cMap[name] = car 98 | } 99 | sort.Strings(cNames) 100 | 101 | return cNames, cMap 102 | } 103 | 104 | // MapIDMSs transforms a slice of IDMSs into a slice of their names and a map 105 | // indexed by their names. 106 | func MapIDMSs(idmss []kion.IDMS) ([]string, map[string]kion.IDMS) { 107 | var iNames []string 108 | iMap := make(map[string]kion.IDMS) 109 | for _, idms := range idmss { 110 | iNames = append(iNames, idms.Name) 111 | iMap[idms.Name] = idms 112 | } 113 | sort.Strings(iNames) 114 | 115 | return iNames, iMap 116 | } 117 | 118 | // MapFavs transforms a slice of Favorites into a slice of their names and a 119 | // map indexed by their names. 120 | func MapFavs(favs []structs.Favorite) ([]string, map[string]structs.Favorite) { 121 | var fNames []string 122 | fMap := make(map[string]structs.Favorite) 123 | for _, fav := range favs { 124 | fNames = append(fNames, fav.DescriptiveName) 125 | fMap[fav.DescriptiveName] = fav 126 | } 127 | sort.Strings(fNames) 128 | 129 | return fNames, fMap 130 | } 131 | 132 | // FindCARByName returns a CAR identified by its name. 133 | func FindCARByName(cars []kion.CAR, carName string) (*kion.CAR, error) { 134 | for _, c := range cars { 135 | if c.Name == carName { 136 | return &c, nil 137 | } 138 | } 139 | return &kion.CAR{}, fmt.Errorf("cannot find cloud access role with name %v", carName) 140 | } 141 | 142 | // CombineFavorites combines local favorites with API favorites, identifying 143 | // exact matches, unaliased matches, conflicts, and local-only favorites. It 144 | // returns a combined slice of all favorites and a detailed comparison struct. 145 | func CombineFavorites(localFavs []structs.Favorite, upstreamFavs []structs.Favorite) ([]structs.Favorite, *structs.FavoritesComparison, error) { 146 | 147 | result := structs.FavoritesComparison{} 148 | upstreamConflictsSeen := make(map[string]bool) 149 | 150 | for _, fav := range upstreamFavs { 151 | // Include metadata with name 152 | fav.DescriptiveName = fmt.Sprintf("%s %s %s", 153 | padName(fav.Name), 154 | color.GreenString("[Kion] "), 155 | color.New(color.Faint).Sprintf("(%s %s %s)", fav.Account, fav.CAR, fav.AccessType), 156 | ) 157 | 158 | result.All = append(result.All, fav) 159 | } 160 | 161 | for _, localFav := range localFavs { 162 | localKey := fmt.Sprintf("%s|%s|%s|%s", localFav.Name, localFav.Account, localFav.CAR, localFav.AccessType) 163 | localUnaliased := fmt.Sprintf("%s|%s|%s", localFav.Account, localFav.CAR, localFav.AccessType) 164 | foundMatch := false 165 | 166 | for _, upstreamFav := range upstreamFavs { 167 | // upstreamFav.AccessType = kion.ConvertAccessType(upstreamFav.AccessType) 168 | upstreamKey := fmt.Sprintf("%s|%s|%s|%s", upstreamFav.Name, upstreamFav.Account, upstreamFav.CAR, upstreamFav.AccessType) 169 | upstreamUnaliased := fmt.Sprintf("%s|%s|%s", upstreamFav.Account, upstreamFav.CAR, upstreamFav.AccessType) 170 | 171 | // Exact match 172 | if upstreamKey == localKey { 173 | foundMatch = true 174 | break 175 | } 176 | 177 | // Name conflict (two with same name but different 178 | // account/CAR/AccessType) 179 | if upstreamFav.Name == localFav.Name && !upstreamFav.Unaliased { 180 | result.ConflictsLocal = append(result.ConflictsLocal, localFav) 181 | if !upstreamConflictsSeen[upstreamKey] { 182 | result.ConflictsUpstream = append(result.ConflictsUpstream, upstreamFav) 183 | upstreamConflictsSeen[upstreamKey] = true 184 | } 185 | localFav.DescriptiveName = fmt.Sprintf("%s %s %s %s", 186 | padName(localFav.Name), 187 | color.New(color.FgBlue).Sprintf("[local]"), 188 | color.New(color.Faint).Sprintf("(%s %s %s)", localFav.Account, localFav.CAR, localFav.AccessType), 189 | color.New(color.FgRed).Sprintf("conflicts w/ %s", upstreamFav.Name), 190 | ) 191 | result.All = append(result.All, localFav) 192 | foundMatch = true 193 | break 194 | } 195 | 196 | // Name conflict (different name but same 197 | // account/CAR/AccessType) 198 | if upstreamUnaliased == localUnaliased && !upstreamFav.Unaliased { 199 | result.ConflictsLocal = append(result.ConflictsLocal, localFav) 200 | if !upstreamConflictsSeen[upstreamKey] { 201 | result.ConflictsUpstream = append(result.ConflictsUpstream, upstreamFav) 202 | upstreamConflictsSeen[upstreamKey] = true 203 | } 204 | localFav.DescriptiveName = fmt.Sprintf("%s %s %s %s", 205 | padName(localFav.Name), 206 | color.New(color.FgBlue).Sprintf("[local]"), 207 | color.New(color.Faint).Sprintf("(%s %s %s)", localFav.Account, localFav.CAR, localFav.AccessType), 208 | color.New(color.FgYellow).Sprintf("duplicate of %s", upstreamFav.Name), 209 | ) 210 | result.All = append(result.All, localFav) 211 | foundMatch = true 212 | break 213 | } 214 | 215 | // Account + CAR + AccessType match (no name) 216 | if upstreamUnaliased == localUnaliased && upstreamFav.Unaliased { 217 | result.UnaliasedLocal = append(result.UnaliasedLocal, localFav) 218 | result.UnaliasedUpstream = append(result.UnaliasedUpstream, upstreamFav) 219 | // Remove upstreamFav from result.All 220 | for i, fav := range result.All { 221 | if fav.Account == upstreamFav.Account && fav.CAR == upstreamFav.CAR && 222 | fav.AccessType == upstreamFav.AccessType && fav.Name == upstreamFav.Name { 223 | result.All = append(result.All[:i], result.All[i+1:]...) 224 | break 225 | } 226 | } 227 | localFav.DescriptiveName = fmt.Sprintf("%s %s %s %s", 228 | padName(localFav.Name), 229 | color.New(color.FgBlue).Sprintf("[local]"), 230 | color.New(color.Faint).Sprintf("(%s %s %s)", localFav.Account, localFav.CAR, localFav.AccessType), 231 | color.New(color.FgYellow).Sprintf("duplicate of unaliased"), 232 | ) 233 | result.All = append(result.All, localFav) 234 | foundMatch = true 235 | break 236 | } 237 | } 238 | 239 | if !foundMatch { 240 | result.LocalOnly = append(result.LocalOnly, localFav) 241 | localFav.DescriptiveName = fmt.Sprintf("%s %s %s", 242 | padName(localFav.Name), 243 | color.New(color.FgBlue).Sprintf("[local]"), 244 | color.New(color.Faint).Sprintf("(%s %s %s)", localFav.Account, localFav.CAR, localFav.AccessType), 245 | ) 246 | result.All = append(result.All, localFav) 247 | } 248 | } 249 | 250 | return result.All, &result, nil 251 | } 252 | -------------------------------------------------------------------------------- /lib/commands/utility.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/fatih/color" 9 | "github.com/kionsoftware/kion-cli/lib/helper" 10 | "github.com/kionsoftware/kion-cli/lib/kion" 11 | "github.com/kionsoftware/kion-cli/lib/structs" 12 | "github.com/urfave/cli/v2" 13 | ) 14 | 15 | //////////////////////////////////////////////////////////////////////////////// 16 | // // 17 | // Helpers // 18 | // // 19 | //////////////////////////////////////////////////////////////////////////////// 20 | 21 | // deleteUpstreamFavorites deletes favorites in the Kion API. It assumes you are 22 | // passing upstream defined favorites only as we don't want to delete local 23 | // only favorites. 24 | func (c *Cmd) deleteUpstreamFavorites(favorites []structs.Favorite) error { 25 | hasErrors := false 26 | for _, f := range favorites { 27 | fmt.Printf(" removing upstream favorite %s: ", f.Name) 28 | _, err := kion.DeleteFavorite(c.config.Kion.URL, c.config.Kion.APIKey, f.Name) 29 | if err != nil { 30 | color.Red("x %s\n", err) 31 | hasErrors = true 32 | continue 33 | } 34 | color.Green("✓") 35 | } 36 | if hasErrors { 37 | return errors.New("one or more favorites failed to delete") 38 | } 39 | return nil 40 | } 41 | 42 | // createUpstreamFavorite creates favorites in the Kion API. It assumes you are 43 | // passing locally defined favorites only as we convert the access type from 44 | // cli to api format. 45 | func (c *Cmd) createUpstreamFavorite(favorites []structs.Favorite) error { 46 | hasErrors := false 47 | for _, f := range favorites { 48 | fmt.Printf(" creating favorite %s: ", f.Name) 49 | f.AccessType = kion.ConvertAccessType(f.AccessType) 50 | _, _, err := kion.CreateFavorite(c.config.Kion.URL, c.config.Kion.APIKey, f) 51 | if err != nil { 52 | color.Red("x %s\n", err) 53 | hasErrors = true 54 | continue 55 | } 56 | color.Green("✓") 57 | } 58 | if hasErrors { 59 | return errors.New("one or more errors occurred during the creation process") 60 | } 61 | return nil 62 | } 63 | 64 | //////////////////////////////////////////////////////////////////////////////// 65 | // // 66 | // Commands // 67 | // // 68 | //////////////////////////////////////////////////////////////////////////////// 69 | 70 | // ValidateSAML validates SAML configuration and connectivity. 71 | func (c *Cmd) ValidateSAML(cCtx *cli.Context) error { 72 | ctx := newValidationContext() 73 | 74 | // Header 75 | fmt.Println() 76 | fmt.Println(ctx.styles.renderMainHeader("SAML Configuration Validation")) 77 | fmt.Println(ctx.styles.renderSeparator()) 78 | fmt.Println() 79 | 80 | // Check basic configuration 81 | if err := c.checkBasicConfig(ctx); err != nil { 82 | return err 83 | } 84 | 85 | // Check port availability 86 | c.checkPortAvailability(ctx) 87 | 88 | // Check Kion connectivity 89 | kionAccessible := c.checkKionConnectivity(ctx) 90 | 91 | // Load and validate metadata 92 | metadata, err := c.loadMetadata(ctx) 93 | if err == nil { 94 | // Validate metadata structure 95 | if c.validateMetadataStructure(ctx, metadata) { 96 | // Validate certificates 97 | c.validateCertificates(ctx, metadata) 98 | 99 | // Check SSO URL reachability 100 | c.checkSSOURLReachability(ctx, metadata) 101 | } 102 | } 103 | fmt.Println() 104 | 105 | // Check CSRF endpoint if Kion is accessible 106 | if kionAccessible { 107 | c.checkCSRFEndpoint(ctx) 108 | } 109 | 110 | // Summary 111 | fmt.Println(ctx.styles.renderSeparator()) 112 | if ctx.allPassed { 113 | var summary strings.Builder 114 | summary.WriteString("✓ All validation checks passed!\n\n") 115 | summary.WriteString("Your SAML configuration appears to be correct.\n") 116 | summary.WriteString("Try running SAML authentication to complete the flow.") 117 | 118 | successBox := ctx.styles.summaryBox.BorderForeground(ctx.styles.checkMark.GetForeground()) 119 | fmt.Println(successBox.Render(summary.String())) 120 | 121 | // Print metadata details after success message 122 | if metadata != nil { 123 | c.printMetadataDetails(ctx, metadata) 124 | } 125 | return nil 126 | } 127 | 128 | var summary strings.Builder 129 | summary.WriteString("✗ Some validation checks failed.\n\n") 130 | summary.WriteString("Please review the errors above and fix the configuration.") 131 | 132 | failBox := ctx.styles.summaryBox.BorderForeground(ctx.styles.xMark.GetForeground()) 133 | fmt.Println(failBox.Render(summary.String())) 134 | return fmt.Errorf("SAML validation failed") 135 | } 136 | 137 | // FlushCache clears the Kion CLI cache. 138 | func (c *Cmd) FlushCache(cCtx *cli.Context) error { 139 | return c.cache.FlushCache() 140 | } 141 | 142 | // PushFavorites pushes the local favorites to a target instance of Kion. 143 | func (c *Cmd) PushFavorites(cCtx *cli.Context) error { 144 | // Exit if not using a compatible Kion version. 145 | if !cCtx.App.Metadata["useFavoritesAPI"].(bool) { 146 | err := errors.New("favorites API is not enabled. This requires Kion version 3.13.5, 3.14.1 or higher") 147 | return err 148 | } 149 | 150 | // Exit if no local favorites are defined. 151 | if len(c.config.Favorites) == 0 { 152 | color.Yellow("No local favorites found for the current profile. Nothing to push.") 153 | return nil 154 | } 155 | 156 | // Track errors during the push process. This will be used to determine if 157 | // we should delete local favorites after the push. 158 | var hasErrors bool 159 | 160 | // Authenticate before making API calls 161 | err := c.setAuthToken(cCtx) 162 | if err != nil { 163 | return err 164 | } 165 | 166 | // Get the combined list of favorites from the CLI config and the Kion API. 167 | apiFavorites, _, err := kion.GetAPIFavorites(c.config.Kion.URL, c.config.Kion.APIKey) 168 | if err != nil { 169 | fmt.Printf("Error retrieving favorites from Kion API: %v\n", err) 170 | return err 171 | } 172 | _, favorites, err := helper.CombineFavorites(c.config.Favorites, apiFavorites) 173 | if err != nil { 174 | fmt.Printf("Error combining favorites: %v\n", err) 175 | return err 176 | } 177 | 178 | // Check if there's anything to push. 179 | changes := len(favorites.LocalOnly) + len(favorites.ConflictsLocal) + len(favorites.UnaliasedLocal) 180 | if changes == 0 { 181 | color.Green("All local favorites are already uploaded to Kion.\n") 182 | return nil 183 | } 184 | 185 | // Build the prompt message. 186 | prompt := fmt.Sprintf("\nThe following local favorites will be pushed to Kion (%v):\n\n", c.config.Kion.URL) 187 | for _, f := range favorites.LocalOnly { 188 | prompt += fmt.Sprintf(" - %s %s\n", f.Name, color.GreenString("(new)")) 189 | } 190 | for _, f := range favorites.ConflictsLocal { 191 | prompt += fmt.Sprintf(" - %s %s\n", f.Name, color.RedString("(upstream conflict)")) 192 | } 193 | for _, f := range favorites.UnaliasedLocal { 194 | prompt += fmt.Sprintf(" - %s %s\n", f.Name, color.YellowString("(will update alias on existing favorite)")) 195 | } 196 | if len(favorites.ConflictsLocal) > 0 { 197 | prompt += fmt.Sprintf("%s\n", color.RedString("\nPushing local favorites with conflicts will overwrite upstream Kion favorites!")) 198 | } 199 | prompt += "\nDo you want to continue?" 200 | 201 | // Confirm the push. 202 | selection, err := helper.PromptSelect(prompt, []string{"no", "yes"}) 203 | if selection == "no" || err != nil { 204 | fmt.Println("\nAborting push of favorites.") 205 | return err 206 | } 207 | if len(favorites.ConflictsLocal) > 0 { 208 | confirm, err := helper.PromptSelect( 209 | "\nConflicting favorites in Kion will be overwritten, are you sure you want to continue?", 210 | []string{"no", "yes"}, 211 | ) 212 | if confirm == "no" || err != nil { 213 | fmt.Println("\nAborting push of favorites due to conflicts.") 214 | return err 215 | } 216 | } 217 | 218 | // Push new local-only favorites. 219 | err = c.createUpstreamFavorite(favorites.LocalOnly) 220 | if err != nil { 221 | hasErrors = true 222 | } 223 | 224 | // Handle conflicts by deleting and recreating. 225 | err = c.deleteUpstreamFavorites(favorites.ConflictsUpstream) 226 | if err != nil { 227 | hasErrors = true 228 | } 229 | err = c.createUpstreamFavorite(favorites.ConflictsLocal) 230 | if err != nil { 231 | hasErrors = true 232 | } 233 | 234 | // Handle unaliased favorites (create will overwrite / update). 235 | err = c.createUpstreamFavorite(favorites.UnaliasedLocal) 236 | if err != nil { 237 | hasErrors = true 238 | } 239 | 240 | // Remove local favorites after successful push. 241 | if !hasErrors { 242 | return c.DeleteLocalFavorites(cCtx) 243 | } else { 244 | return errors.New("one or more errors occurred, local favorites have not been deleted") 245 | } 246 | } 247 | 248 | func (c *Cmd) DeleteLocalFavorites(cCtx *cli.Context) error { 249 | confirmDelete, err := helper.PromptSelect("\nDo you want to delete the local favorites?", []string{"no", "yes"}) 250 | if err != nil { 251 | color.Red("Error prompting for deletion confirmation: %v\n", err) 252 | return err 253 | } 254 | if confirmDelete == "yes" { 255 | 256 | configPath := cCtx.App.Metadata["configPath"].(string) 257 | 258 | // load the full config file 259 | var config structs.Configuration 260 | err := helper.LoadConfig(configPath, &config) 261 | if err != nil { 262 | color.Red("Error loading config: %v\n", err) 263 | return err 264 | } 265 | 266 | // if using a profile, delete favorites from that profile 267 | // otherwise delete favorites from the default profile 268 | profile := cCtx.String("profile") 269 | if profile == "" { 270 | config.Favorites = []structs.Favorite{} 271 | } else { 272 | profileConfig := config.Profiles[profile] 273 | profileConfig.Favorites = []structs.Favorite{} 274 | config.Profiles[profile] = profileConfig 275 | } 276 | 277 | // Save the updated config back to the file 278 | err = helper.SaveConfig(configPath, config) 279 | if err != nil { 280 | color.Red("Error saving updated config: %v\n", err) 281 | return err 282 | } 283 | color.Green("\nLocal favorites deleted after successful push to Kion API.\n") 284 | } else { 285 | color.Green("\nKeeping local favorites.\n") 286 | } 287 | 288 | return nil 289 | } 290 | -------------------------------------------------------------------------------- /lib/commands/commands.go: -------------------------------------------------------------------------------- 1 | // Package commands provides the command implementations for the Kion CLI tool. 2 | package commands 3 | 4 | import ( 5 | "fmt" 6 | "time" 7 | 8 | "github.com/99designs/keyring" 9 | "github.com/hashicorp/go-version" 10 | "github.com/kionsoftware/kion-cli/lib/cache" 11 | "github.com/kionsoftware/kion-cli/lib/helper" 12 | "github.com/kionsoftware/kion-cli/lib/kion" 13 | "github.com/kionsoftware/kion-cli/lib/structs" 14 | 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | //////////////////////////////////////////////////////////////////////////////// 19 | // // 20 | // Commands Object // 21 | // // 22 | //////////////////////////////////////////////////////////////////////////////// 23 | 24 | // Cmd is the main command object for the Kion CLI. It makes the configuration 25 | // and cache available to all command actions. 26 | type Cmd struct { 27 | config *structs.Configuration 28 | cache cache.Cache 29 | } 30 | 31 | // NewCommands stands up a new instance of commands with the provided 32 | // configuration. 33 | func NewCommands(cfg *structs.Configuration) *Cmd { 34 | return &Cmd{ 35 | config: cfg, 36 | } 37 | } 38 | 39 | //////////////////////////////////////////////////////////////////////////////// 40 | // // 41 | // Helpers // 42 | // // 43 | //////////////////////////////////////////////////////////////////////////////// 44 | 45 | // getSecondArgument returns the second positional argument passed to the cli. 46 | func getSecondArgument(cCtx *cli.Context) string { 47 | if cCtx.Args().Len() > 0 { 48 | return cCtx.Args().Get(0) 49 | } 50 | return "" 51 | } 52 | 53 | // getThirdArgument returns the third positional argument passed to the cli. 54 | func getThirdArgument(cCtx *cli.Context) string { 55 | if cCtx.Args().Len() > 0 { 56 | return cCtx.Args().Get(1) 57 | } 58 | return "" 59 | } 60 | 61 | // setEndpoint sets the target Kion installation to interact with. If not 62 | // passed to the tool as an argument, set in the env, or present in the 63 | // configuration dotfile it will prompt the user to provide it. 64 | func (c *Cmd) setEndpoint() error { 65 | if c.config.Kion.URL == "" { 66 | kionURL, err := helper.PromptInput("Kion URL:") 67 | if err != nil { 68 | return err 69 | } 70 | c.config.Kion.URL = kionURL 71 | } 72 | return nil 73 | } 74 | 75 | // getActionAndBuffer determines the action based on the passed flags and sets 76 | // a buffer for the associated action used to determine the cache validity. 77 | func getActionAndBuffer(cCtx *cli.Context) (string, time.Duration) { 78 | // grab the command usage [stak, s, setenv, savecreds, etc] 79 | cmdUsed := cCtx.Lineage()[1].Args().Slice()[0] 80 | 81 | var action string 82 | var buffer time.Duration 83 | if cCtx.Bool("credential-process") { 84 | action = "credential-process" 85 | buffer = 5 86 | } else if cCtx.Bool("print") || cmdUsed == "setenv" { 87 | action = "print" 88 | buffer = 300 89 | } else if cCtx.Bool("save") || cmdUsed == "savecreds" { 90 | action = "save" 91 | buffer = 600 92 | } else { 93 | action = "subshell" 94 | buffer = 300 95 | } 96 | 97 | return action, buffer 98 | } 99 | 100 | // authStakCache handles the common pattern of authenticating the user, 101 | // grabbing a STAK, and caching it. Used to dry up code in various commands. 102 | func (c *Cmd) authStakCache(cCtx *cli.Context, carName string, accNum string, accAlias string) (kion.STAK, error) { 103 | // handle auth 104 | err := c.setAuthToken(cCtx) 105 | if err != nil { 106 | return kion.STAK{}, err 107 | } 108 | 109 | // generate short term tokens 110 | stak, err := kion.GetSTAK(c.config.Kion.URL, c.config.Kion.APIKey, carName, accNum, accAlias) 111 | if err != nil { 112 | return kion.STAK{}, err 113 | } 114 | 115 | // store the stak in the cache 116 | err = c.cache.SetStak(carName, accNum, accAlias, stak) 117 | if err != nil { 118 | return kion.STAK{}, err 119 | } 120 | 121 | return stak, err 122 | } 123 | 124 | // initCache initializes the cache based on the configuration. If the cache 125 | // is disabled or the user has requested to flush the cache, a null cache is 126 | // used. Otherwise, a real cache is initialized using the keyring library. 127 | func (c *Cmd) initCache(cCtx *cli.Context) error { 128 | // if the cache is not disabled, or if the user has requested to flush the 129 | // cache, we initialize the real cache. Otherwise, we use a null cache. 130 | if !c.config.Kion.DisableCache || getThirdArgument(cCtx) == "flush-cache" { 131 | if c.config.Kion.DebugMode { 132 | keyring.Debug = true 133 | } 134 | // initialize the keyring 135 | name := "kion-cli" 136 | ring, err := keyring.Open(keyring.Config{ 137 | ServiceName: name, 138 | KeyCtlScope: "session", 139 | 140 | // osx 141 | KeychainName: "login", 142 | KeychainTrustApplication: true, 143 | KeychainSynchronizable: false, 144 | 145 | // kde wallet 146 | KWalletAppID: name, 147 | KWalletFolder: name, 148 | 149 | // gnome wallet (libsecret) 150 | LibSecretCollectionName: "login", 151 | 152 | // windows 153 | WinCredPrefix: name, 154 | 155 | // password store 156 | PassPrefix: name, 157 | 158 | // encrypted file fallback 159 | FileDir: "~/.kion", 160 | FilePasswordFunc: helper.PromptPassword, 161 | }) 162 | if err != nil { 163 | return err 164 | } 165 | c.cache = cache.NewCache(ring) 166 | } else { 167 | c.cache = cache.NewNullCache() 168 | } 169 | 170 | return nil 171 | } 172 | 173 | // handleProfile checks if a profile is specified and if so, it overrides the 174 | // default configuration with the profile's values. It also honors any global 175 | // flags that were set in the CLI context, allowing them to take precedence over 176 | // the profile's values. 177 | func (c *Cmd) handleProfile(profileName string, cCtx *cli.Context) error { 178 | if profileName != "" { 179 | // grab all manually set global flags so we can honor them over the chosen 180 | // profiles values 181 | // bool values need to be explicitly handled here since we're not iterating 182 | setStrings := make(map[string]string) 183 | 184 | var disableCacheFlagged bool 185 | var debugFlagged bool 186 | var quietFlagged bool 187 | 188 | setGlobalFlags := cCtx.FlagNames() 189 | for _, flag := range setGlobalFlags { 190 | switch flag { 191 | case "endpoint": 192 | setStrings["endpoint"] = c.config.Kion.URL 193 | case "user": 194 | setStrings["user"] = c.config.Kion.Username 195 | case "password": 196 | setStrings["password"] = c.config.Kion.Password 197 | case "idms": 198 | setStrings["idms"] = c.config.Kion.IDMS 199 | case "saml-metadata-file": 200 | setStrings["saml-metadata-file"] = c.config.Kion.SamlMetadataFile 201 | case "saml-sp-issuer": 202 | setStrings["saml-sp-issuer"] = c.config.Kion.SamlIssuer 203 | case "token": 204 | setStrings["token"] = c.config.Kion.APIKey 205 | // non-string flags 206 | case "disable-cache": 207 | disableCacheFlagged = true 208 | case "debug": 209 | debugFlagged = true 210 | case "quiet": 211 | quietFlagged = true 212 | } 213 | } 214 | 215 | // grab the profile and if found and not empty override the default config 216 | profile, found := c.config.Profiles[profileName] 217 | if found { 218 | c.config.Kion = profile.Kion 219 | c.config.Favorites = profile.Favorites 220 | } else { 221 | return fmt.Errorf("profile not found: %s", profileName) 222 | } 223 | 224 | // honor any global flags that were set to maintain precedence 225 | for key, value := range setStrings { 226 | err := cCtx.Set(key, value) 227 | if err != nil { 228 | return err 229 | } 230 | } 231 | 232 | // handle non-string flags 233 | if disableCacheFlagged { 234 | c.config.Kion.DisableCache = true 235 | } 236 | if debugFlagged { 237 | c.config.Kion.DebugMode = true 238 | } 239 | if quietFlagged { 240 | c.config.Kion.QuietMode = true 241 | } 242 | } 243 | return nil 244 | } 245 | 246 | //////////////////////////////////////////////////////////////////////////////// 247 | // // 248 | // Before & After Commands // 249 | // // 250 | //////////////////////////////////////////////////////////////////////////////// 251 | 252 | // BeforeCommands run after the context is ready but before any subcommands are 253 | // executed. Currently used to test feature compatibility with targeted Kion. 254 | func (c *Cmd) BeforeCommands(cCtx *cli.Context) error { 255 | // skip before bits if we don't need them (ie we're just printing help) 256 | args := cCtx.Args().Slice() 257 | if len(args) == 0 || args[0] == "help" || args[0] == "h" { 258 | return nil 259 | } 260 | 261 | // switch profiles if specified 262 | profileName := cCtx.String("profile") 263 | err := c.handleProfile(profileName, cCtx) 264 | if err != nil { 265 | return err 266 | } 267 | 268 | // grab the Kion url if not already set 269 | err = c.setEndpoint() 270 | if err != nil { 271 | return err 272 | } 273 | 274 | // gather the targeted Kion version 275 | kionVer, err := kion.GetVersion(c.config.Kion.URL) 276 | if err != nil { 277 | return err 278 | } 279 | curVer, err := version.NewSemver(kionVer) 280 | if err != nil { 281 | return err 282 | } 283 | 284 | // api/v3/me/cloud-access-role fix constraints 285 | v3mecarC1, _ := version.NewConstraint(">=3.6.29, < 3.7.0") 286 | v3mecarC2, _ := version.NewConstraint(">=3.7.17, < 3.8.0") 287 | v3mecarC3, _ := version.NewConstraint(">=3.8.9, < 3.9.0") 288 | v3mecarC4, _ := version.NewConstraint(">=3.9.0") 289 | 290 | // check constraints and set bool in metadata 291 | if v3mecarC1.Check(curVer) || 292 | v3mecarC2.Check(curVer) || 293 | v3mecarC3.Check(curVer) || 294 | v3mecarC4.Check(curVer) { 295 | cCtx.App.Metadata["useUpdatedCloudAccessRoleAPI"] = true 296 | } 297 | 298 | // SAML metadata file handling 299 | newSaml, _ := version.NewConstraint(">=3.8.0") 300 | if !newSaml.Check(curVer) { 301 | cCtx.App.Metadata["useOldSAML"] = true 302 | } 303 | 304 | // favorites API check 305 | favoritesAPI1, _ := version.NewConstraint(">=3.13.9, < 3.14.0") 306 | favoritesAPI2, _ := version.NewConstraint(">=3.14.3") 307 | if favoritesAPI1.Check(curVer) || favoritesAPI2.Check(curVer) { 308 | cCtx.App.Metadata["useFavoritesAPI"] = true 309 | } 310 | 311 | // initialize the cache 312 | err = c.initCache(cCtx) 313 | if err != nil { 314 | return err 315 | } 316 | 317 | return nil 318 | } 319 | 320 | // AfterCommands run after any subcommands are executed. 321 | func (c *Cmd) AfterCommands(cCtx *cli.Context) error { 322 | return nil 323 | } 324 | -------------------------------------------------------------------------------- /lib/helper/browser.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "os/exec" 10 | "regexp" 11 | "runtime" 12 | "time" 13 | 14 | "github.com/kionsoftware/kion-cli/lib/structs" 15 | ) 16 | 17 | //////////////////////////////////////////////////////////////////////////////// 18 | // // 19 | // Browser // 20 | // // 21 | //////////////////////////////////////////////////////////////////////////////// 22 | 23 | // redirectServer runs a temp go http server to handle logging out any existing 24 | // AWS sessions then redirecting to the federated console login. 25 | func redirectServer(url string, typeID uint) { 26 | // stub out a new mux 27 | mux := http.NewServeMux() 28 | 29 | // handles logout from aws and redirection to login 30 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 31 | redirPage := ` 32 | 33 | 34 |
35 | 36 |