diff --git a/CHANGELOG.md b/CHANGELOG.md index ccafe151ca..9cc0a0aaae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ Canonical reference for changes, improvements, and bugfixes for Boundary. ## Next +### Deprecations/Changes + +* Permissions in new scope default roles have been updated to include support + for `list`, `read:self`, and `delete:self` on `auth-token` resources. This + allows a user to list and manage their own authentication tokens. (As is the + case with other resources, `list` will still be limited to returning tokens on + which the user has authorization to perform actions, so granting this + capability does not automatically give user the ability to list other users' + authentication tokens.) + ### New and Improved * actions: The new `no-op` action allows a grant to be given to a principals @@ -13,17 +23,23 @@ Canonical reference for changes, improvements, and bugfixes for Boundary. `read` or other capabilities on the resources. The default scope permissions have been updated to convey `no-op,list` instead of `read,list`. [PR](https://github.com/hashicorp/boundary/pull/1138) -* cli/api/sdk: User resources have new attributes for: +* cli/api/sdk: User resources have new attributes for: * Primary Account ID * Login Name - * Full Name - * Email - + * Full Name + * Email These new user attributes correspond to attributes from the user's primary - auth method account. These attributes will be empty when the user has no + auth method account. These attributes will be empty when the user has no account in the primary auth method for their scope, or there is no designated - primary auth method for their scope. - + primary auth method for their scope. +* cli: Support for reading and deleting the user's own token via the new + `read:self` and `delete:self` actions on auth tokens. If no token ID is + provided, the stored token's ID will be used (after prompting), or `"self"` + can be set to the ID to trigger this behavior without prompting. + ([PR](https://github.com/hashicorp/boundary/pull/1162)) +* cli: New `logout` command deletes the current token in Boundary and forgets it + from the local system credential store + ([PR](https://github.com/hashicorp/boundary/pull/1134)) ### Bug Fixes @@ -253,10 +269,11 @@ database migrate` command. * controller/worker: Require names to be all lowercase. This removes ambiguity or accidental mismatching when using upcoming filtering features. -* api/cli: Due to visibility changes on collection listing, a list - will not include any resources if the user only has `list` as an authorized action. - As a result `scope list`, which is used by the UI to populate the login scope dropdown, - will be empty if the role granting the `u_anon` user `list` privileges is not updated to also contain a `read` action +* api/cli: Due to visibility changes on collection listing, a list will not + include any resources if the user only has `list` as an authorized action. As + a result `scope list`, which is used by the UI to populate the login scope + dropdown, will be empty if the role granting the `u_anon` user `list` + privileges is not updated to also contain a `read` action ### New and Improved @@ -269,13 +286,14 @@ database migrate` command. * api/cli: Most resource types now support recursive listing, allowing listing to occur down a scope tree ([PR](https://github.com/hashicorp/boundary/pull/885)) -* cli: Add a `database migrate` command which updates a database's schema to - the version supported by the boundary binary ([PR](https://github.com/hashicorp/boundary/pull/872)). +* cli: Add a `database migrate` command which updates a database's schema to the + version supported by the boundary binary + ([PR](https://github.com/hashicorp/boundary/pull/872)). ### Bug Fixes -* controller/db: Correctly check if db init previously completed successfully - when starting a controller or when running `database init` +* controller/db: Correctly check if db init previously completed successfully + when starting a controller or when running `database init` ([Issue](https://github.com/hashicorp/boundary/issues/805)) ([PR](https://github.com/hashicorp/boundary/pull/842)) * cli: When `output-curl-string` is used with `update` or `add-/remove-/set-` @@ -284,8 +302,8 @@ database migrate` command. fetches the current version ([Issue](https://github.com/hashicorp/boundary/issues/856)) ([PR](https://github.com/hashicorp/boundary/pull/858)) -* db: Fix panic in `database init` when controller config block is missing - ([Issue](https://github.com/hashicorp/boundary/issues/819)) +* db: Fix panic in `database init` when controller config block is missing + ([Issue](https://github.com/hashicorp/boundary/issues/819)) ([PR](https://github.com/hashicorp/boundary/pull/851)) ## 0.1.4 (2021/01/05) @@ -325,8 +343,8 @@ database migrate` command. ([PR](https://github.com/hashicorp/boundary/pull/831)) * controller: Improved error handling in hosts, host catalog and host set ([PR](https://github.com/hashicorp/boundary/pull/786)) -* controller: Relax account login name constraints to allow dash as valid character - ([Issue](https://github.com/hashicorp/boundary/issues/759)) +* controller: Relax account login name constraints to allow dash as valid + character ([Issue](https://github.com/hashicorp/boundary/issues/759)) ([PR](https://github.com/hashicorp/boundary/pull/806)) * cli/connect/http: Pass endpoint address through to allow setting TLS server name directly in most cases diff --git a/internal/cmd/base/base.go b/internal/cmd/base/base.go index c374cb524f..7e3b8e3478 100644 --- a/internal/cmd/base/base.go +++ b/internal/cmd/base/base.go @@ -3,8 +3,6 @@ package base import ( "bytes" "context" - "encoding/base64" - "encoding/json" "flag" "fmt" "io" @@ -12,19 +10,15 @@ import ( "os" "os/signal" "regexp" - "runtime" "strings" "sync" "syscall" "github.com/hashicorp/boundary/api" - "github.com/hashicorp/boundary/api/authtokens" "github.com/hashicorp/boundary/sdk/wrapper" - nkeyring "github.com/jefferai/keyring" "github.com/mitchellh/cli" "github.com/pkg/errors" "github.com/posener/complete" - zkeyring "github.com/zalando/go-keyring" ) const ( @@ -126,7 +120,7 @@ func MakeShutdownCh() chan struct{} { // Client returns the HTTP API client. The client is cached on the command to // save performance on future calls. func (c *Command) Client(opt ...Option) (*api.Client, error) { - // Read the test client if present + // Read the cached client if present if c.client != nil { return c.client, nil } @@ -242,143 +236,6 @@ func (c *Command) Client(opt ...Option) (*api.Client, error) { return c.client, nil } -func (c *Command) DiscoverKeyringTokenInfo() (string, string, error) { - tokenName := "default" - - if c.FlagTokenName != "" { - tokenName = c.FlagTokenName - } - - if tokenName == "none" { - c.UI.Warn(`"-token-name=none" is deprecated, please use "-keyring-type=none"`) - c.FlagKeyringType = "none" - } - - if c.FlagKeyringType == "none" { - return "", "", nil - } - - // Set so we can look it up later when printing out curl strings - os.Setenv(EnvTokenName, tokenName) - - var foundKeyringType bool - keyringType := c.FlagKeyringType - switch runtime.GOOS { - case "windows": - switch keyringType { - case "auto", "wincred", "pass": - foundKeyringType = true - if keyringType == "auto" { - keyringType = "wincred" - } - } - case "darwin": - switch keyringType { - case "auto", "keychain", "pass": - foundKeyringType = true - if keyringType == "auto" { - keyringType = "keychain" - } - } - default: - switch keyringType { - case "auto", "secret-service", "pass": - foundKeyringType = true - if keyringType == "auto" { - keyringType = "pass" - } - } - } - - if !foundKeyringType { - return "", "", fmt.Errorf("Given keyring type %q is not valid, or not valid for this platform", c.FlagKeyringType) - } - - var available bool - switch keyringType { - case "wincred", "keychain": - available = true - case "pass", "secret-service": - avail := nkeyring.AvailableBackends() - for _, a := range avail { - if keyringType == string(a) { - available = true - } - } - } - - if !available { - return "", "", fmt.Errorf("Keyring type %q is not available on this machine. For help with setting up keyrings, see https://www.boundaryproject.io/docs/api-clients/cli.", keyringType) - } - - os.Setenv(EnvKeyringType, keyringType) - - return keyringType, tokenName, nil -} - -func (c *Command) ReadTokenFromKeyring(keyringType, tokenName string) *authtokens.AuthToken { - var token string - var err error - - switch keyringType { - case "none": - return nil - - case "wincred", "keychain": - token, err = zkeyring.Get(StoredTokenName, tokenName) - if err != nil { - if err == zkeyring.ErrNotFound { - c.UI.Error("No saved credential found, continuing without") - } else { - c.UI.Error(fmt.Sprintf("Error reading auth token from keyring: %s", err)) - c.UI.Warn("Token must be provided via BOUNDARY_TOKEN env var or -token flag. Reading the token can also be disabled via -keyring-type=none.") - } - token = "" - } - - default: - krConfig := nkeyring.Config{ - LibSecretCollectionName: "login", - PassPrefix: "HashiCorp_Boundary", - AllowedBackends: []nkeyring.BackendType{nkeyring.BackendType(keyringType)}, - } - - kr, err := nkeyring.Open(krConfig) - if err != nil { - c.UI.Error(fmt.Sprintf("Error opening keyring: %s", err)) - c.UI.Warn("Token must be provided via BOUNDARY_TOKEN env var or -token flag. Reading the token can also be disabled via -keyring-type=none.") - break - } - - item, err := kr.Get(tokenName) - if err != nil { - c.UI.Error(fmt.Sprintf("Error fetching token from keyring: %s", err)) - c.UI.Warn("Token must be provided via BOUNDARY_TOKEN env var or -token flag. Reading the token can also be disabled via -keyring-type=none.") - break - } - - token = string(item.Data) - } - - if token != "" { - tokenBytes, err := base64.RawStdEncoding.DecodeString(token) - switch { - case err != nil: - c.UI.Error(fmt.Errorf("Error base64-unmarshaling stored token from system credential store: %w", err).Error()) - case len(tokenBytes) == 0: - c.UI.Error("Zero length token after decoding stored token from system credential store") - default: - var authToken authtokens.AuthToken - if err := json.Unmarshal(tokenBytes, &authToken); err != nil { - c.UI.Error(fmt.Sprintf("Error unmarshaling stored token information after reading from system credential store: %s", err)) - } else { - return &authToken - } - } - } - return nil -} - type FlagSetBit uint const ( diff --git a/internal/cmd/base/initial_resources.go b/internal/cmd/base/initial_resources.go index a951f307fa..dfe5cfa3a7 100644 --- a/internal/cmd/base/initial_resources.go +++ b/internal/cmd/base/initial_resources.go @@ -62,6 +62,7 @@ func (b *Server) CreateInitialLoginRole(ctx context.Context) (*iam.Role, error) "id=*;type=scope;actions=list,no-op", "id=*;type=auth-method;actions=authenticate,list", "id={{account.id}};actions=read,change-password", + "id=*;type=auth-token;actions=list,read:self,delete:self", }); err != nil { return nil, fmt.Errorf("error creating grant for default generated grants: %w", err) } @@ -517,7 +518,8 @@ func (b *Server) CreateInitialTarget(ctx context.Context) (target.Target, error) b.Info["generated target id"] = b.DevTargetId // If we have an unprivileged dev user, add user to the role that grants - // list/read:self/cancel:self, and an authorize-session role + // list/read:self/cancel:self on sessions, read:self/delete:self/list on + // tokens, and an authorize-session role if b.DevUnprivilegedUserId != "" { iamRepo, err := iam.NewRepository(rw, rw, kmsCache, iam.WithRandomReader(b.SecureRandomReader)) if err != nil { diff --git a/internal/cmd/base/keyring.go b/internal/cmd/base/keyring.go new file mode 100644 index 0000000000..eddf28d80b --- /dev/null +++ b/internal/cmd/base/keyring.go @@ -0,0 +1,173 @@ +package base + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "runtime" + "strings" + + "github.com/hashicorp/boundary/api/authtokens" + nkeyring "github.com/jefferai/keyring" + "github.com/pkg/errors" + zkeyring "github.com/zalando/go-keyring" +) + +const ( + NoneKeyring = "none" + AutoKeyring = "auto" + WincredKeyring = "wincred" + PassKeyring = "pass" + KeychainKeyring = "keychain" + SecretServiceKeyring = "secret-service" + + DefaultTokenName = "default" + LoginCollection = "login" + PassPrefix = "HashiCorp_Boundary" +) + +func (c *Command) DiscoverKeyringTokenInfo() (string, string, error) { + tokenName := DefaultTokenName + + if c.FlagTokenName != "" { + tokenName = c.FlagTokenName + } + + if tokenName == NoneKeyring { + c.UI.Warn(`"-token-name=none" is deprecated, please use "-keyring-type=none"`) + c.FlagKeyringType = NoneKeyring + } + + if c.FlagKeyringType == NoneKeyring { + return "", "", nil + } + + // Set so we can look it up later when printing out curl strings + os.Setenv(EnvTokenName, tokenName) + + var foundKeyringType bool + keyringType := c.FlagKeyringType + switch runtime.GOOS { + case "windows": + switch keyringType { + case AutoKeyring, WincredKeyring, PassKeyring: + foundKeyringType = true + if keyringType == AutoKeyring { + keyringType = WincredKeyring + } + } + case "darwin": + switch keyringType { + case AutoKeyring, KeychainKeyring, PassKeyring: + foundKeyringType = true + if keyringType == AutoKeyring { + keyringType = KeychainKeyring + } + } + default: + switch keyringType { + case AutoKeyring, SecretServiceKeyring, PassKeyring: + foundKeyringType = true + if keyringType == AutoKeyring { + keyringType = PassKeyring + } + } + } + + if !foundKeyringType { + return "", "", fmt.Errorf("Given keyring type %q is not valid, or not valid for this platform", c.FlagKeyringType) + } + + var available bool + switch keyringType { + case WincredKeyring, KeychainKeyring: + available = true + case PassKeyring, SecretServiceKeyring: + avail := nkeyring.AvailableBackends() + for _, a := range avail { + if keyringType == string(a) { + available = true + } + } + } + + if !available { + return "", "", fmt.Errorf("Keyring type %q is not available on this machine. For help with setting up keyrings, see https://www.boundaryproject.io/docs/api-clients/cli.", keyringType) + } + + os.Setenv(EnvKeyringType, keyringType) + + return keyringType, tokenName, nil +} + +func (c *Command) ReadTokenFromKeyring(keyringType, tokenName string) *authtokens.AuthToken { + var token string + var err error + + switch keyringType { + case NoneKeyring: + return nil + + case WincredKeyring, KeychainKeyring: + token, err = zkeyring.Get(StoredTokenName, tokenName) + if err != nil { + if err == zkeyring.ErrNotFound { + c.UI.Error("No saved credential found, continuing without") + } else { + c.UI.Error(fmt.Sprintf("Error reading auth token from keyring: %s", err)) + c.UI.Warn("Token must be provided via BOUNDARY_TOKEN env var or -token flag. Reading the token can also be disabled via -keyring-type=none.") + } + token = "" + } + + default: + krConfig := nkeyring.Config{ + LibSecretCollectionName: LoginCollection, + PassPrefix: PassPrefix, + AllowedBackends: []nkeyring.BackendType{nkeyring.BackendType(keyringType)}, + } + + kr, err := nkeyring.Open(krConfig) + if err != nil { + c.UI.Error(fmt.Sprintf("Error opening keyring: %s", err)) + c.UI.Warn("Token must be provided via BOUNDARY_TOKEN env var or -token flag. Reading the token can also be disabled via -keyring-type=none.") + break + } + + item, err := kr.Get(tokenName) + if err != nil { + c.UI.Error(fmt.Sprintf("Error fetching token from keyring: %s", err)) + c.UI.Warn("Token must be provided via BOUNDARY_TOKEN env var or -token flag. Reading the token can also be disabled via -keyring-type=none.") + break + } + + token = string(item.Data) + } + + if token != "" { + tokenBytes, err := base64.RawStdEncoding.DecodeString(token) + switch { + case err != nil: + c.UI.Error(fmt.Errorf("Error base64-unmarshaling stored token from system credential store: %w", err).Error()) + case len(tokenBytes) == 0: + c.UI.Error("Zero length token after decoding stored token from system credential store") + default: + var authToken authtokens.AuthToken + if err := json.Unmarshal(tokenBytes, &authToken); err != nil { + c.UI.Error(fmt.Sprintf("Error unmarshaling stored token information after reading from system credential store: %s", err)) + } else { + return &authToken + } + } + } + return nil +} + +func TokenIdFromToken(token string) (string, error) { + split := strings.Split(token, "_") + if len(split) < 3 { + return "", errors.New("Unexpected stored token format") + } + return strings.Join(split[0:2], "_"), nil +} diff --git a/internal/cmd/commands.go b/internal/cmd/commands.go index 2670a466d3..e84dfa8b74 100644 --- a/internal/cmd/commands.go +++ b/internal/cmd/commands.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/boundary/internal/cmd/commands/hostcatalogscmd" "github.com/hashicorp/boundary/internal/cmd/commands/hostscmd" "github.com/hashicorp/boundary/internal/cmd/commands/hostsetscmd" + "github.com/hashicorp/boundary/internal/cmd/commands/logout" "github.com/hashicorp/boundary/internal/cmd/commands/rolescmd" "github.com/hashicorp/boundary/internal/cmd/commands/scopescmd" "github.com/hashicorp/boundary/internal/cmd/commands/server" @@ -539,6 +540,12 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { }, nil }, + "logout": func() (cli.Command, error) { + return &logout.LogoutCommand{ + Command: base.NewCommand(ui), + }, nil + }, + "roles": func() (cli.Command, error) { return &rolescmd.Command{ Command: base.NewCommand(ui), diff --git a/internal/cmd/commands/authtokenscmd/authtokens.gen.go b/internal/cmd/commands/authtokenscmd/authtokens.gen.go index 807be1b9ba..4e79232070 100644 --- a/internal/cmd/commands/authtokenscmd/authtokens.gen.go +++ b/internal/cmd/commands/authtokenscmd/authtokens.gen.go @@ -130,11 +130,6 @@ func (c *Command) Run(args []string) int { return base.CommandUserError } - if strutil.StrListContains(flagsMap[c.Func], "id") && c.FlagId == "" { - c.PrintCliError(errors.New("ID is required but not passed in via -id")) - return base.CommandUserError - } - var opts []authtokens.Option if strutil.StrListContains(flagsMap[c.Func], "scope-id") { diff --git a/internal/cmd/commands/authtokenscmd/funcs.go b/internal/cmd/commands/authtokenscmd/funcs.go index cfc6273a29..f84fafdcdd 100644 --- a/internal/cmd/commands/authtokenscmd/funcs.go +++ b/internal/cmd/commands/authtokenscmd/funcs.go @@ -1,13 +1,60 @@ package authtokenscmd import ( + "errors" "fmt" "time" "github.com/hashicorp/boundary/api/authtokens" "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/sdk/strutil" ) +const selfFlag = "self" + +func init() { + extraFlagsHandlingFunc = extraFlagsHandlingFuncImpl +} + +func extraFlagsHandlingFuncImpl(c *Command, _ *base.FlagSets, _ *[]authtokens.Option) bool { + if c.Func != "delete" && c.Func != "read" { + if strutil.StrListContains(flagsMap[c.Func], "id") && c.FlagId == "" { + c.PrintCliError(errors.New("ID is required but not passed in via -id")) + return false + } + return true + } + + if c.FlagId == "" { + fmt.Printf("No ID provided; %s the stored token? (Pass an ID of %q to suppress this question.) y/n: ", c.Func, selfFlag) + var yesNo string + fmt.Scanf("%s", &yesNo) + switch yesNo { + case "y", "Y": + c.FlagId = selfFlag + default: + c.PrintCliError(errors.New(`"Y" or "y" not provided, refusing to continue`)) + return false + } + } + + if c.FlagId == selfFlag { + // We should have already read this and have it cached + client, err := c.Client() + if err != nil { + c.PrintCliError(fmt.Errorf("Error reading cached API client: %w", err)) + return false + } + c.FlagId, err = base.TokenIdFromToken(client.Token()) + if err != nil { + c.PrintCliError(err) + return false + } + } + + return true +} + func (c *Command) printListTable(items []*authtokens.AuthToken) string { if len(items) == 0 { return "No auth tokens found" diff --git a/internal/cmd/commands/config/token.go b/internal/cmd/commands/config/token.go index fef3e4d7ca..a445d03237 100644 --- a/internal/cmd/commands/config/token.go +++ b/internal/cmd/commands/config/token.go @@ -1,8 +1,6 @@ package config import ( - "fmt" - "github.com/hashicorp/boundary/api/authtokens" "github.com/hashicorp/boundary/internal/cmd/base" "github.com/mitchellh/cli" @@ -25,7 +23,7 @@ type TokenCommand struct { } func (c *TokenCommand) Synopsis() string { - return fmt.Sprintf("Get the stored token, or its properties") + return "Get the stored token, or its properties" } func (c *TokenCommand) Help() string { @@ -125,27 +123,29 @@ func (c *TokenCommand) Run(args []string) (ret int) { // Read from client first as that will override keyring anyways var authToken *authtokens.AuthToken - // Fallback to env/CLI - client, err := c.Client() - if err != nil { - c.UI.Error(err.Error()) - return base.CommandCliError - } - if client.Token() != "" { - authToken = &authtokens.AuthToken{Token: client.Token()} - } - - if authToken == nil { + if optCount == 1 { + // In this case we need to read the full auth token stored, not just the + // actual token value for the client. keyringType, tokenName, err := c.DiscoverKeyringTokenInfo() if err != nil { c.UI.Error(err.Error()) return base.CommandCliError } - authToken = c.ReadTokenFromKeyring(keyringType, tokenName) + } else { + // Fallback to env/CLI but we can only get just the token value this way, at + // least for now + client, err := c.Client() + if err != nil { + c.UI.Error(err.Error()) + return base.CommandCliError + } + if client.Token() != "" { + authToken = &authtokens.AuthToken{Token: client.Token()} + } } - if authToken == nil { + if authToken == nil || authToken.Token == "" { c.UI.Error("No token could be discovered") return base.CommandCliError } @@ -170,9 +170,6 @@ func (c *TokenCommand) Run(args []string) (ret int) { c.UI.Output(authToken.AuthMethodId) default: - if authToken.Token == "" { - return base.CommandUserError - } c.UI.Output(authToken.Token) } diff --git a/internal/cmd/commands/logout/logout.go b/internal/cmd/commands/logout/logout.go new file mode 100644 index 0000000000..8f5ed45562 --- /dev/null +++ b/internal/cmd/commands/logout/logout.go @@ -0,0 +1,161 @@ +package logout + +import ( + "errors" + "fmt" + "net/http" + + "github.com/hashicorp/boundary/api" + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/internal/cmd/base" + nkeyring "github.com/jefferai/keyring" + "github.com/mitchellh/cli" + "github.com/posener/complete" + zkeyring "github.com/zalando/go-keyring" +) + +var ( + _ cli.Command = (*LogoutCommand)(nil) + _ cli.CommandAutocomplete = (*LogoutCommand)(nil) +) + +type LogoutCommand struct { + *base.Command + + Func string +} + +func (c *LogoutCommand) Synopsis() string { + return "Delete the current token within Boundary and forget it locally" +} + +func (c *LogoutCommand) Help() string { + var args []string + args = append(args, + "Usage: boundary logout [options]", + "", + " Delete the current token (as selected by -token-name) within Boundary and forget it from the local store. Example:", + "", + ` $ boundary logout`, + "", + ) + + return base.WrapForHelpText(args) + c.Flags().Help() +} + +func (c *LogoutCommand) Flags() *base.FlagSets { + set := c.FlagSet(base.FlagSetNone) + + f := set.NewFlagSet("Command Options") + + f.StringVar(&base.StringVar{ + Name: "token-name", + Target: &c.FlagTokenName, + EnvVar: base.EnvTokenName, + Usage: `If specified, the given value will be used as the name when loading the token from the system credential store. This must correspond to a name used when authenticating.`, + }) + + f.StringVar(&base.StringVar{ + Name: "keyring-type", + Target: &c.FlagKeyringType, + Default: "auto", + EnvVar: base.EnvKeyringType, + Usage: `The type of keyring to use. Defaults to "auto" which will use the Windows credential manager, OSX keychain, or cross-platform password store depending on platform. Set to "none" to disable keyring functionality. Available types, depending on platform, are: "wincred", "keychain", "pass", and "secret-service".`, + }) + + return set +} + +func (c *LogoutCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictAnything +} + +func (c *LogoutCommand) AutocompleteFlags() complete.Flags { + return c.Flags().Completions() +} + +func (c *LogoutCommand) Run(args []string) (ret int) { + f := c.Flags() + if err := f.Parse(args); err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + + client, err := c.Client() + if err != nil { + c.PrintCliError(fmt.Errorf("Error reading API client: %w", err)) + return base.CommandCliError + } + + if client.Token() == "" { + c.PrintCliError(errors.New("Empty or no token found in store. It might have already been deleted.")) + return base.CommandUserError + } + + id, err := base.TokenIdFromToken(client.Token()) + if err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + + authtokensClient := authtokens.NewClient(client) + _, err = authtokensClient.Delete(c.Context, id) + if apiErr := api.AsServerError(err); apiErr != nil && apiErr.Response().StatusCode() == http.StatusNotFound { + c.UI.Output("The token was not found on the Boundary controller; proceeding to delete from the local store.") + goto DeleteLocal + } + if err != nil { + if apiErr := api.AsServerError(err); apiErr != nil { + c.PrintApiError(apiErr, "Error from controller when performing delete on token") + return base.CommandApiError + } + c.PrintCliError(fmt.Errorf("Error trying to delete auth token: %w", err)) + return base.CommandCliError + } + + c.UI.Output("The token was successfully deleted within the Boundary controller.") + +DeleteLocal: + keyringType, tokenName, err := c.DiscoverKeyringTokenInfo() + if err != nil { + c.PrintCliError(fmt.Errorf("Error fetching keyring information to delete local stored token: %w", err)) + return base.CommandCliError + } + if keyringType == "none" || + tokenName == "none" || + keyringType == "" || + tokenName == "" { + c.UI.Output("Keyring type set to none or empty; not deleting local stored token.") + return base.CommandSuccess + } + + switch keyringType { + case "wincred", "keychain": + if err := zkeyring.Delete(base.StoredTokenName, tokenName); err != nil { + c.PrintCliError(fmt.Errorf("Error deleting auth token from %q keyring: %w", keyringType, err)) + return base.CommandCliError + } + + default: + krConfig := nkeyring.Config{ + LibSecretCollectionName: "login", + PassPrefix: "HashiCorp_Boundary", + AllowedBackends: []nkeyring.BackendType{nkeyring.BackendType(keyringType)}, + } + + kr, err := nkeyring.Open(krConfig) + if err != nil { + c.PrintCliError(fmt.Errorf("Error opening %q keyring: %w", keyringType, err)) + return base.CommandCliError + } + + if err := kr.Remove(tokenName); err != nil { + c.PrintCliError(fmt.Errorf("Error deleting token from %q keyring: %w", keyringType, err)) + return base.CommandCliError + } + } + + c.UI.Output("The token was successfully removed from the local credential store.") + + return base.CommandSuccess +} diff --git a/internal/cmd/gencli/input.go b/internal/cmd/gencli/input.go index 3a9b7720b1..29f27cc4d9 100644 --- a/internal/cmd/gencli/input.go +++ b/internal/cmd/gencli/input.go @@ -155,7 +155,6 @@ var inputStructs = map[string][]*cmdInfo{ Pkg: "authtokens", StdActions: []string{"read", "delete", "list"}, Container: "Scope", - HasId: true, }, }, "groups": { diff --git a/internal/iam/repository_scope.go b/internal/iam/repository_scope.go index 7711b7e6da..51650c2037 100644 --- a/internal/iam/repository_scope.go +++ b/internal/iam/repository_scope.go @@ -313,6 +313,12 @@ func (r *Repository) CreateScope(ctx context.Context, s *Scope, userId string, o return errors.Wrap(err, op, errors.WithMsg("unable to create in memory role grant")) } grants = append(grants, roleGrant) + + roleGrant, err = NewRoleGrant(defaultRolePublicId, "id=*;type=auth-token;actions=list,read:self,delete:self") + if err != nil { + return errors.Wrap(err, op, errors.WithMsg("unable to create in memory role grant")) + } + grants = append(grants, roleGrant) } roleGrantOplogMsgs := make([]*oplog.Message, 0, 3) diff --git a/internal/perms/grants.go b/internal/perms/grants.go index 58f0e050ba..1e3b78ee66 100644 --- a/internal/perms/grants.go +++ b/internal/perms/grants.go @@ -385,6 +385,7 @@ func (g Grant) validateType() error { resource.Role, resource.AuthMethod, resource.Account, + resource.AuthToken, resource.HostCatalog, resource.HostSet, resource.Host, diff --git a/internal/servers/controller/handlers/authtokens/authtoken_service.go b/internal/servers/controller/handlers/authtokens/authtoken_service.go index e08bff06d8..556966c4f2 100644 --- a/internal/servers/controller/handlers/authtokens/authtoken_service.go +++ b/internal/servers/controller/handlers/authtokens/authtoken_service.go @@ -24,7 +24,9 @@ var ( IdActions = action.ActionSet{ action.NoOp, action.Read, + action.ReadSelf, action.Delete, + action.DeleteSelf, } // CollectionActions contains the set of actions that can be performed on @@ -99,10 +101,17 @@ func (s Service) ListAuthTokens(ctx context.Context, req *pbs.ListAuthTokensRequ for _, item := range ul { item.Scope = scopeInfoMap[item.GetScopeId()] res.ScopeId = item.Scope.Id - item.AuthorizedActions = authResults.FetchActionSetForId(ctx, item.Id, IdActions, auth.WithResource(res)).Strings() - if len(item.AuthorizedActions) == 0 { + authorizedActions := authResults.FetchActionSetForId(ctx, item.Id, IdActions, auth.WithResource(res)) + if len(authorizedActions) == 0 { continue } + + if authorizedActions.OnlySelf() && item.GetUserId() != authResults.UserId { + continue + } + + item.AuthorizedActions = authorizedActions.Strings() + if filter.Match(item) { finalItems = append(finalItems, item) } @@ -115,7 +124,7 @@ func (s Service) GetAuthToken(ctx context.Context, req *pbs.GetAuthTokenRequest) if err := validateGetRequest(req); err != nil { return nil, err } - authResults := s.authResult(ctx, req.GetId(), action.Read) + authResults := s.authResult(ctx, req.GetId(), action.ReadSelf) if authResults.Error != nil { return nil, authResults.Error } @@ -123,6 +132,15 @@ func (s Service) GetAuthToken(ctx context.Context, req *pbs.GetAuthTokenRequest) if err != nil { return nil, err } + + authzdActions := authResults.FetchActionSetForId(ctx, u.Id, IdActions) + // Check to see if we need to verify Read vs. just ReadSelf + if u.GetUserId() != authResults.UserId { + if !authzdActions.HasAction(action.Read) { + return nil, handlers.ForbiddenError() + } + } + u.Scope = authResults.Scope u.AuthorizedActions = authResults.FetchActionSetForId(ctx, u.Id, IdActions).Strings() return &pbs.GetAuthTokenResponse{Item: u}, nil @@ -133,11 +151,24 @@ func (s Service) DeleteAuthToken(ctx context.Context, req *pbs.DeleteAuthTokenRe if err := validateDeleteRequest(req); err != nil { return nil, err } - authResults := s.authResult(ctx, req.GetId(), action.Delete) + authResults := s.authResult(ctx, req.GetId(), action.DeleteSelf) if authResults.Error != nil { return nil, authResults.Error } - _, err := s.deleteFromRepo(ctx, req.GetId()) + + at, err := s.getFromRepo(ctx, req.GetId()) + if err != nil { + return nil, err + } + authzdActions := authResults.FetchActionSetForId(ctx, at.Id, IdActions) + // Check to see if we need to verify Delete vs. just DeleteSelf + if at.GetUserId() != authResults.UserId { + if !authzdActions.HasAction(action.Delete) { + return nil, handlers.ForbiddenError() + } + } + + _, err = s.deleteFromRepo(ctx, req.GetId()) if err != nil { return nil, err } diff --git a/internal/servers/controller/handlers/authtokens/authtoken_service_test.go b/internal/servers/controller/handlers/authtokens/authtoken_service_test.go index ad4f76c025..0589fc99fd 100644 --- a/internal/servers/controller/handlers/authtokens/authtoken_service_test.go +++ b/internal/servers/controller/handlers/authtokens/authtoken_service_test.go @@ -1,8 +1,10 @@ package authtokens_test import ( + "context" "errors" "fmt" + "net/http/httptest" "testing" "github.com/google/go-cmp/cmp" @@ -14,9 +16,11 @@ import ( pbs "github.com/hashicorp/boundary/internal/gen/controller/api/services" "github.com/hashicorp/boundary/internal/iam" "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/servers" "github.com/hashicorp/boundary/internal/servers/controller/handlers" "github.com/hashicorp/boundary/internal/servers/controller/handlers/authtokens" "github.com/hashicorp/boundary/internal/types/scope" + "github.com/hashicorp/go-hclog" "google.golang.org/grpc/codes" "google.golang.org/protobuf/testing/protocmp" @@ -24,7 +28,89 @@ import ( "github.com/stretchr/testify/require" ) -var testAuthorizedActions = []string{"no-op", "read", "delete"} +var testAuthorizedActions = []string{"no-op", "read", "read:self", "delete", "delete:self"} + +func TestGetSelf(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrap := db.TestWrapper(t) + logger := hclog.New(nil) + kms := kms.TestKms(t, conn, wrap) + + iamRepoFn := func() (*iam.Repository, error) { + return iam.TestRepo(t, conn, wrap), nil + } + tokenRepoFn := func() (*authtoken.Repository, error) { + return authtoken.NewRepository(rw, rw, kms) + } + serversRepoFn := func() (*servers.Repository, error) { + return servers.NewRepository(rw, rw, kms) + } + + a, err := authtokens.NewService(tokenRepoFn, iamRepoFn) + require.NoError(t, err, "Couldn't create new auth token service.") + + o, _ := iam.TestScopes(t, iam.TestRepo(t, conn, wrap)) + at1 := authtoken.TestAuthToken(t, conn, kms, o.GetPublicId()) + at2 := authtoken.TestAuthToken(t, conn, kms, o.GetPublicId()) + + cases := []struct { + name string + token *authtoken.AuthToken + readId string + err error + }{ + { + name: "at1 read self", + token: at1, + readId: at1.GetPublicId(), + }, + { + name: "at1 read at2", + token: at1, + readId: at2.GetPublicId(), + err: handlers.ApiErrorWithCodeAndMessage(codes.PermissionDenied, "Forbidden."), + }, + { + name: "at2 read self", + token: at2, + readId: at2.GetPublicId(), + }, + { + name: "at2 read at1", + token: at2, + readId: at1.GetPublicId(), + err: handlers.ApiErrorWithCodeAndMessage(codes.PermissionDenied, "Forbidden."), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require, assert := require.New(t), assert.New(t) + // Setup the auth request information + req := httptest.NewRequest("GET", fmt.Sprintf("http://127.0.0.1/v1/auth-tokens/%s", tc.readId), nil) + requestInfo := auth.RequestInfo{ + Path: req.URL.Path, + Method: req.Method, + TokenFormat: auth.AuthTokenTypeBearer, + PublicId: tc.token.GetPublicId(), + Token: tc.token.GetToken(), + } + + ctx := auth.NewVerifierContext(context.Background(), logger, iamRepoFn, tokenRepoFn, serversRepoFn, kms, requestInfo) + got, err := a.GetAuthToken(ctx, &pbs.GetAuthTokenRequest{Id: tc.readId}) + if tc.err != nil { + require.EqualError(err, tc.err.Error()) + require.Nil(got) + return + } + require.NoError(err) + require.NotNil(got) + assert.Equal(got.GetItem().GetId(), tc.token.GetPublicId()) + // Ensure we didn't simply have e.g. read on all tokens + assert.Equal(got.Item.GetAuthorizedActions(), []string{"read:self", "delete:self"}) + }) + } +} func TestGet(t *testing.T) { conn, _ := db.TestSetup(t, "postgres") @@ -101,6 +187,75 @@ func TestGet(t *testing.T) { } } +func TestList_Self(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrap := db.TestWrapper(t) + logger := hclog.New(nil) + kms := kms.TestKms(t, conn, wrap) + + iamRepo := iam.TestRepo(t, conn, wrap) + + iamRepoFn := func() (*iam.Repository, error) { + return iamRepo, nil + } + tokenRepoFn := func() (*authtoken.Repository, error) { + return authtoken.NewRepository(rw, rw, kms) + } + serversRepoFn := func() (*servers.Repository, error) { + return servers.NewRepository(rw, rw, kms) + } + + // This will result in the scope having default permissions, which now + // includes list on auth tokens + o, _ := iam.TestScopes(t, iamRepo) + + // Each of these should only end up being able to list themselves + at := authtoken.TestAuthToken(t, conn, kms, o.GetPublicId()) + otherAt := authtoken.TestAuthToken(t, conn, kms, o.GetPublicId()) + + cases := []struct { + name string + requester *authtoken.AuthToken + count int + }{ + { + name: "First token sees only self", + requester: at, + }, + { + name: "Second token sees only self", + requester: otherAt, + }, + } + + a, err := authtokens.NewService(tokenRepoFn, iamRepoFn) + require.NoError(t, err) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require, assert := require.New(t), assert.New(t) + // Setup the auth request information + req := httptest.NewRequest("GET", fmt.Sprintf("http://127.0.0.1/v1/auth-tokens?scope_id=%s", o.GetPublicId()), nil) + requestInfo := auth.RequestInfo{ + Path: req.URL.Path, + Method: req.Method, + TokenFormat: auth.AuthTokenTypeBearer, + PublicId: tc.requester.GetPublicId(), + Token: tc.requester.GetToken(), + } + + ctx := auth.NewVerifierContext(context.Background(), logger, iamRepoFn, tokenRepoFn, serversRepoFn, kms, requestInfo) + got, err := a.ListAuthTokens(ctx, &pbs.ListAuthTokensRequest{ScopeId: o.GetPublicId()}) + require.NoError(err) + require.Len(got.Items, 1) + assert.Equal(got.Items[0].GetId(), tc.requester.GetPublicId()) + // Ensure we didn't simply have e.g. read on all tokens + assert.Equal(got.Items[0].GetAuthorizedActions(), []string{"read:self", "delete:self"}) + }) + } +} + func TestList(t *testing.T) { conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) @@ -243,6 +398,98 @@ func TestList(t *testing.T) { } } +func TestDeleteSelf(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrap := db.TestWrapper(t) + logger := hclog.New(nil) + kms := kms.TestKms(t, conn, wrap) + + iamRepo := iam.TestRepo(t, conn, wrap) + + iamRepoFn := func() (*iam.Repository, error) { + return iamRepo, nil + } + tokenRepoFn := func() (*authtoken.Repository, error) { + return authtoken.NewRepository(rw, rw, kms) + } + serversRepoFn := func() (*servers.Repository, error) { + return servers.NewRepository(rw, rw, kms) + } + + a, err := authtokens.NewService(tokenRepoFn, iamRepoFn) + require.NoError(t, err, "Couldn't create new auth token service.") + + o, _ := iam.TestScopes(t, iam.TestRepo(t, conn, wrap)) + at1 := authtoken.TestAuthToken(t, conn, kms, o.GetPublicId()) + at2 := authtoken.TestAuthToken(t, conn, kms, o.GetPublicId()) + + cases := []struct { + name string + token *authtoken.AuthToken + deleteId string + err error + }{ + { + name: "at1 delete at2", + token: at1, + deleteId: at2.GetPublicId(), + err: handlers.ApiErrorWithCodeAndMessage(codes.PermissionDenied, "Forbidden."), + }, + { + name: "at2 delete at1", + token: at2, + deleteId: at1.GetPublicId(), + err: handlers.ApiErrorWithCodeAndMessage(codes.PermissionDenied, "Forbidden."), + }, + { + name: "at1 delete self", + token: at1, + deleteId: at1.GetPublicId(), + }, + { + name: "at2 delete self", + token: at2, + deleteId: at2.GetPublicId(), + }, + { + name: "at1 not found", + token: at1, + deleteId: at1.GetPublicId(), + err: handlers.ApiErrorWithCodeAndMessage(codes.NotFound, "Resource not found."), + }, + { + name: "at2 not found", + token: at2, + deleteId: at2.GetPublicId(), + err: handlers.ApiErrorWithCodeAndMessage(codes.NotFound, "Resource not found."), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require := require.New(t) + // Setup the auth request information + req := httptest.NewRequest("DELETE", fmt.Sprintf("http://127.0.0.1/v1/auth-tokens/%s", tc.deleteId), nil) + requestInfo := auth.RequestInfo{ + Path: req.URL.Path, + Method: req.Method, + TokenFormat: auth.AuthTokenTypeBearer, + PublicId: tc.token.GetPublicId(), + Token: tc.token.GetToken(), + } + + ctx := auth.NewVerifierContext(context.Background(), logger, iamRepoFn, tokenRepoFn, serversRepoFn, kms, requestInfo) + got, err := a.DeleteAuthToken(ctx, &pbs.DeleteAuthTokenRequest{Id: tc.deleteId}) + if tc.err != nil { + require.EqualError(err, tc.err.Error()) + require.Nil(got) + return + } + require.NoError(err) + }) + } +} + func TestDelete(t *testing.T) { conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) diff --git a/internal/servers/controller/handlers/sessions/session_service.go b/internal/servers/controller/handlers/sessions/session_service.go index 97e12f9cc7..b8dcd55a56 100644 --- a/internal/servers/controller/handlers/sessions/session_service.go +++ b/internal/servers/controller/handlers/sessions/session_service.go @@ -77,14 +77,7 @@ func (s Service) GetSession(ctx context.Context, req *pbs.GetSessionRequest) (*p authzdActions := authResults.FetchActionSetForId(ctx, ses.Id, IdActions) // Check to see if we need to verify Read vs. just ReadSelf if ses.GetUserId() != authResults.UserId { - var found bool - for _, v := range authzdActions { - if v == action.Read { - found = true - break - } - } - if !found { + if !authzdActions.HasAction(action.Read) { return nil, handlers.ForbiddenError() } } @@ -143,14 +136,8 @@ func (s Service) ListSessions(ctx context.Context, req *pbs.ListSessionsRequest) if len(authorizedActions) == 0 { continue } - onlySelf := true - for _, v := range authorizedActions { - if v != action.ReadSelf && v != action.CancelSelf { - onlySelf = false - break - } - } - if onlySelf && item.GetUserId() != authResults.UserId { + + if authorizedActions.OnlySelf() && item.GetUserId() != authResults.UserId { continue } @@ -179,16 +166,9 @@ func (s Service) CancelSession(ctx context.Context, req *pbs.CancelSessionReques return nil, err } authzdActions := authResults.FetchActionSetForId(ctx, ses.Id, IdActions) - // Check to see if we need to verify Read vs. just ReadSelf + // Check to see if we need to verify Cancel vs. just CancelSelf if ses.GetUserId() != authResults.UserId { - var found bool - for _, v := range authzdActions { - if v == action.Cancel { - found = true - break - } - } - if !found { + if !authzdActions.HasAction(action.Cancel) { return nil, handlers.ForbiddenError() } } diff --git a/internal/tests/cli/boundary/_auth_tokens.bash b/internal/tests/cli/boundary/_auth_tokens.bash new file mode 100644 index 0000000000..dd33a7a1cd --- /dev/null +++ b/internal/tests/cli/boundary/_auth_tokens.bash @@ -0,0 +1,34 @@ +function read_token() { + if [[ "x$1" == "x" ]] + then + echo "y" | boundary auth-tokens read + else + boundary auth-tokens read -id $1 + fi +} + +function delete_token() { + if [[ "x$1" == "x" ]] + then + echo "y" | boundary auth-tokens delete + else + boundary auth-tokens delete -id $1 + fi +} + +function token_id() { + local tid=$1 + strip $(read_token $tid | jq '.item.id') +} + +function logout_cmd() { + boundary logout +} + +function get_token() { + boundary config get-token +} + +function read_token_no_keyring() { + boundary auth-tokens read -keyring-type=none -id $1 +} \ No newline at end of file diff --git a/internal/tests/cli/boundary/auth_token.bats b/internal/tests/cli/boundary/auth_token.bats new file mode 100644 index 0000000000..0e8fd44d61 --- /dev/null +++ b/internal/tests/cli/boundary/auth_token.bats @@ -0,0 +1,72 @@ +#!/usr/bin/env bats + +load _auth +load _auth_tokens +load _helpers + +export NEW_USER='test' + +@test "boundary/token: can login as unpriv user" { + run login $DEFAULT_UNPRIVILEGED_LOGIN + [ "$status" -eq 0 ] +} + +@test "boundary/token: can read own token with no id given" { + run read_token "" + [ "$status" -eq 0 ] +} + +@test "boundary/token: can read own token with self given" { + run read_token "self" + [ "$status" -eq 0 ] +} + +@test "boundary/token: can read own token id given" { + local tid=$(token_id "self") + run read_token "$tid" + [ "$status" -eq 0 ] +} + +@test "boundary/token: can delete own token with no id given" { + run login $DEFAULT_UNPRIVILEGED_LOGIN + [ "$status" -eq 0 ] + run delete_token "" + [ "$status" -eq 0 ] + run read_token "" + [ "$status" -eq 1 ] +} + +@test "boundary/token: can delete own token with self given" { + run login $DEFAULT_UNPRIVILEGED_LOGIN + [ "$status" -eq 0 ] + run delete_token "self" + [ "$status" -eq 0 ] + run read_token "" + [ "$status" -eq 1 ] +} + +@test "boundary/token: can delete own token with id given" { + run login $DEFAULT_UNPRIVILEGED_LOGIN + [ "$status" -eq 0 ] + run token_id "self" + [ "$status" -eq 0 ] + tid=$(echo "$output") + run delete_token $tid + [ "$status" -eq 0 ] + run read_token $tid + [ "$status" -eq 1 ] +} + +@test "boundary/token/logout: can logout" { + run login $DEFAULT_UNPRIVILEGED_LOGIN + [ "$status" -eq 0 ] + run get_token + [ "$status" -eq 0 ] + local tid=$(token_id "self") + run logout_cmd + [ "$status" -eq 0 ] + run get_token + [ "$status" -eq 2 ] + run read_token_no_keyring $tid + [ "$status" -eq 1 ] +} diff --git a/internal/tests/cli/boundary/roles.bats b/internal/tests/cli/boundary/roles.bats index 3dad1416ca..6546f9efff 100644 --- a/internal/tests/cli/boundary/roles.bats +++ b/internal/tests/cli/boundary/roles.bats @@ -49,8 +49,6 @@ export NEW_GRANT='id=*;type=*;actions=create,read,update,delete,list' [ "$status" -eq 0 ] } - - @test "boundary/role/add-principals: $NEW_ROLE role contains default principal" { local rid=$(role_id $NEW_ROLE $DEFAULT_GLOBAL) run role_has_principal_id $rid $DEFAULT_USER diff --git a/internal/types/action/action.go b/internal/types/action/action.go index b6edf97f19..0c5e68a981 100644 --- a/internal/types/action/action.go +++ b/internal/types/action/action.go @@ -1,5 +1,7 @@ package action +import "strings" + // Type defines a type for the Actions of Resources // actions are also stored as a lookup db table named iam_action type Type uint @@ -40,7 +42,8 @@ const ( ReadSelf Type = 31 CancelSelf Type = 32 ChangeState Type = 33 - NoOp Type = 34 + DeleteSelf Type = 34 + NoOp Type = 35 ) var Map = map[string]Type{ @@ -77,6 +80,7 @@ var Map = map[string]Type{ ReadSelf.String(): ReadSelf, CancelSelf.String(): CancelSelf, ChangeState.String(): ChangeState, + DeleteSelf.String(): DeleteSelf, NoOp.String(): NoOp, } @@ -116,6 +120,7 @@ func (a Type) String() string { "read:self", "cancel:self", "change-state", + "delete:self", "no-op", }[a] } @@ -134,3 +139,28 @@ func (a ActionSet) Strings() []string { } return ret } + +// HasAction returns whether the action set contains the given action. +func (a ActionSet) HasAction(act Type) bool { + for _, v := range a { + if v == act { + return true + } + } + return false +} + +// OnlySelf returns true if all actions in the action set are self types. An +// empty set returns false. This may not be what you want so the caller should +// validate length and act appropriately as well. +func (a ActionSet) OnlySelf() bool { + if len(a) == 0 { + return false + } + for _, v := range a { + if !strings.HasSuffix(v.String(), ":self") { + return false + } + } + return true +} diff --git a/internal/types/action/action_test.go b/internal/types/action/action_test.go index ebd5c6303f..c9de615721 100644 --- a/internal/types/action/action_test.go +++ b/internal/types/action/action_test.go @@ -87,6 +87,10 @@ func TestAction(t *testing.T) { action: ChangeState, want: "change-state", }, + { + action: DeleteSelf, + want: "delete:self", + }, { action: NoOp, want: "no-op", @@ -128,3 +132,87 @@ func TestActionStrings(t *testing.T) { }) } } + +func TestHasAction(t *testing.T) { + tests := []struct { + name string + actions ActionSet + action Type + want bool + }{ + { + name: "has 1", + actions: ActionSet{Read, AuthorizeSession}, + action: Read, + want: true, + }, + { + name: "has 2", + actions: ActionSet{Read, AuthorizeSession}, + action: AuthorizeSession, + want: true, + }, + { + name: "empty", + actions: ActionSet{}, + action: AuthorizeSession, + want: false, + }, + { + name: "does not have 1", + actions: ActionSet{Read, AuthorizeSession}, + action: ReadSelf, + want: false, + }, + { + name: "does not have 2", + actions: ActionSet{Read, AuthorizeSession}, + action: Delete, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.actions.HasAction(tt.action)) + }) + } +} + +func TestOnlySelf(t *testing.T) { + tests := []struct { + name string + actions ActionSet + want bool + }{ + { + name: "has only self 1", + actions: ActionSet{ReadSelf, CancelSelf}, + want: true, + }, + { + name: "has only self 2", + actions: ActionSet{ReadSelf}, + want: true, + }, + { + name: "empty is false", + actions: ActionSet{}, + want: false, + }, + { + name: "does not have only self 1", + actions: ActionSet{Read, AuthorizeSession}, + want: false, + }, + { + name: "does not have only self 2", + actions: ActionSet{ReadSelf, AuthorizeSession}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.actions.OnlySelf()) + }) + } +}