From 51a7df50bbd91a3cb8079f4469589052c3f487c7 Mon Sep 17 00:00:00 2001 From: James Rasell Date: Wed, 17 Aug 2022 14:49:52 +0100 Subject: [PATCH] cli: add ability to create and view tokens with ACL role links. --- command/acl_bootstrap.go | 61 ++++++++++++++++++++++---------- command/acl_token_create.go | 54 ++++++++++++++++++++++++---- command/acl_token_create_test.go | 26 ++++++++++++++ command/acl_token_info.go | 2 +- command/acl_token_self.go | 2 +- command/acl_token_update.go | 2 +- helper/funcs.go | 11 ++++++ helper/funcs_test.go | 24 +++++++++++++ 8 files changed, 154 insertions(+), 28 deletions(-) diff --git a/command/acl_bootstrap.go b/command/acl_bootstrap.go index f367f9cb9..a1844365c 100644 --- a/command/acl_bootstrap.go +++ b/command/acl_bootstrap.go @@ -8,6 +8,7 @@ import ( "time" "github.com/hashicorp/nomad/api" + "github.com/mitchellh/cli" "github.com/posener/complete" ) @@ -127,7 +128,7 @@ func (c *ACLBootstrapCommand) Run(args []string) int { } // Format the output - c.Ui.Output(formatKVACLToken(token)) + outputACLToken(c.Ui, token) return 0 } @@ -143,32 +144,56 @@ func formatKVPolicy(policy *api.ACLPolicy) string { return formatKV(output) } -// formatKVACLToken returns a K/V formatted ACL token -func formatKVACLToken(token *api.ACLToken) string { - // Add the fixed preamble - output := []string{ +// outputACLToken formats and outputs the ACL token via the UI in the correct +// format. +func outputACLToken(ui cli.Ui, token *api.ACLToken) { + + // Build the initial KV output which is always the same not matter whether + // the token is a management or client type. + kvOutput := []string{ fmt.Sprintf("Accessor ID|%s", token.AccessorID), fmt.Sprintf("Secret ID|%s", token.SecretID), fmt.Sprintf("Name|%s", token.Name), fmt.Sprintf("Type|%s", token.Type), fmt.Sprintf("Global|%v", token.Global), - } - - // Special case the policy output - if token.Type == "management" { - output = append(output, "Policies|n/a") - } else { - output = append(output, fmt.Sprintf("Policies|%v", token.Policies)) - } - - // Add the generic output - output = append(output, fmt.Sprintf("Create Time|%v", token.CreateTime), fmt.Sprintf("Expiry Time |%s", expiryTimeString(token.ExpirationTime)), fmt.Sprintf("Create Index|%d", token.CreateIndex), fmt.Sprintf("Modify Index|%d", token.ModifyIndex), - ) - return formatKV(output) + } + + // If the token is a management type, make it obvious that it is not + // possible to have policies or roles assigned to it and just output the + // KV data. + if token.Type == "management" { + kvOutput = append(kvOutput, "Policies|n/a", "Roles|n/a") + ui.Output(formatKV(kvOutput)) + } else { + + // Policies are only currently referenced by name, so keep the previous + // format. When/if policies gain an ID alongside name like roles, this + // output should follow that of the roles. + kvOutput = append(kvOutput, fmt.Sprintf("Policies|%v", token.Policies)) + + var roleOutput []string + + // If we have linked roles, add the ID and name in a list format to the + // output. Otherwise, make it clear there are no linked roles. + if len(token.Roles) > 0 { + roleOutput = append(roleOutput, "ID|Name") + for _, roleLink := range token.Roles { + roleOutput = append(roleOutput, roleLink.ID+"|"+roleLink.Name) + } + } else { + roleOutput = append(roleOutput, "") + } + + // Output the mixed formats of data, ensuring there is a space between + // the KV and list data. + ui.Output(formatKV(kvOutput)) + ui.Output("") + ui.Output(fmt.Sprintf("Roles\n%s", formatList(roleOutput))) + } } func expiryTimeString(t *time.Time) string { diff --git a/command/acl_token_create.go b/command/acl_token_create.go index de1962849..e15d38be6 100644 --- a/command/acl_token_create.go +++ b/command/acl_token_create.go @@ -5,12 +5,17 @@ import ( "strings" "time" + "github.com/hashicorp/go-set" "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/helper" "github.com/posener/complete" ) type ACLTokenCreateCommand struct { Meta + + roleNames []string + roleIDs []string } func (c *ACLTokenCreateCommand) Help() string { @@ -38,6 +43,12 @@ Create Options: Specifies a policy to associate with the token. Can be specified multiple times, but only with client type tokens. + -role-id + ID of a role to use for this token. May be specified multiple times. + + -role-name + Name of a role to use for this token. May be specified multiple times. + -ttl Specifies the time-to-live of the created ACL token. This takes the form of a time duration such as "5m" and "1h". By default, tokens will be created @@ -49,11 +60,13 @@ Create Options: func (c *ACLTokenCreateCommand) AutocompleteFlags() complete.Flags { return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), complete.Flags{ - "name": complete.PredictAnything, - "type": complete.PredictAnything, - "global": complete.PredictNothing, - "policy": complete.PredictAnything, - "ttl": complete.PredictAnything, + "name": complete.PredictAnything, + "type": complete.PredictAnything, + "global": complete.PredictNothing, + "policy": complete.PredictAnything, + "role-id": complete.PredictAnything, + "role-name": complete.PredictAnything, + "ttl": complete.PredictAnything, }) } @@ -81,6 +94,14 @@ func (c *ACLTokenCreateCommand) Run(args []string) int { policies = append(policies, s) return nil }), "policy", "") + flags.Var((funcVar)(func(s string) error { + c.roleNames = append(c.roleNames, s) + return nil + }), "role-name", "") + flags.Var((funcVar)(func(s string) error { + c.roleIDs = append(c.roleIDs, s) + return nil + }), "role-id", "") if err := flags.Parse(args); err != nil { return 1 } @@ -93,11 +114,12 @@ func (c *ACLTokenCreateCommand) Run(args []string) int { return 1 } - // Setup the token + // Set up the token. tk := &api.ACLToken{ Name: name, Type: tokenType, Policies: policies, + Roles: generateACLTokenRoleLinks(c.roleNames, c.roleIDs), Global: global, } @@ -127,6 +149,24 @@ func (c *ACLTokenCreateCommand) Run(args []string) int { } // Format the output - c.Ui.Output(formatKVACLToken(token)) + outputACLToken(c.Ui, token) return 0 } + +// generateACLTokenRoleLinks takes the command input role links by ID and name +// and coverts this to the relevant API object. It handles de-duplicating +// entries to the best effort, so this doesn't need to be done on the leader. +func generateACLTokenRoleLinks(roleNames, roleIDs []string) []*api.ACLTokenRoleLink { + var tokenLinks []*api.ACLTokenRoleLink + + roleNameSet := set.From[string](roleNames).List() + roleNameFn := func(name string) *api.ACLTokenRoleLink { return &api.ACLTokenRoleLink{Name: name} } + + roleIDsSet := set.From[string](roleIDs).List() + roleIDFn := func(id string) *api.ACLTokenRoleLink { return &api.ACLTokenRoleLink{ID: id} } + + tokenLinks = append(tokenLinks, helper.ConvertSlice(roleNameSet, roleNameFn)...) + tokenLinks = append(tokenLinks, helper.ConvertSlice(roleIDsSet, roleIDFn)...) + + return tokenLinks +} diff --git a/command/acl_token_create_test.go b/command/acl_token_create_test.go index 8eb782686..b2d7c08cb 100644 --- a/command/acl_token_create_test.go +++ b/command/acl_token_create_test.go @@ -3,6 +3,7 @@ package command import ( "testing" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" "github.com/hashicorp/nomad/command/agent" "github.com/mitchellh/cli" @@ -50,3 +51,28 @@ func TestACLTokenCreateCommand(t *testing.T) { out = ui.OutputWriter.String() require.NotContains(t, out, "Expiry Time = ") } + +func Test_generateACLTokenRoleLinks(t *testing.T) { + ci.Parallel(t) + + inputRoleNames := []string{ + "duplicate", + "policy1", + "policy2", + "duplicate", + } + inputRoleIDs := []string{ + "77a780d8-2dee-7c7f-7822-6f5471c5cbb2", + "56850b06-a343-a772-1a5c-ad083fd8a50e", + "77a780d8-2dee-7c7f-7822-6f5471c5cbb2", + "77a780d8-2dee-7c7f-7822-6f5471c5cbb2", + } + expectedOutput := []*api.ACLTokenRoleLink{ + {Name: "duplicate"}, + {Name: "policy1"}, + {Name: "policy2"}, + {ID: "77a780d8-2dee-7c7f-7822-6f5471c5cbb2"}, + {ID: "56850b06-a343-a772-1a5c-ad083fd8a50e"}, + } + require.ElementsMatch(t, generateACLTokenRoleLinks(inputRoleNames, inputRoleIDs), expectedOutput) +} diff --git a/command/acl_token_info.go b/command/acl_token_info.go index 7df77ee1d..cb0c12651 100644 --- a/command/acl_token_info.go +++ b/command/acl_token_info.go @@ -71,6 +71,6 @@ func (c *ACLTokenInfoCommand) Run(args []string) int { } // Format the output - c.Ui.Output(formatKVACLToken(token)) + outputACLToken(c.Ui, token) return 0 } diff --git a/command/acl_token_self.go b/command/acl_token_self.go index eac79e481..9e3516346 100644 --- a/command/acl_token_self.go +++ b/command/acl_token_self.go @@ -68,6 +68,6 @@ func (c *ACLTokenSelfCommand) Run(args []string) int { } // Format the output - c.Ui.Output(formatKVACLToken(token)) + outputACLToken(c.Ui, token) return 0 } diff --git a/command/acl_token_update.go b/command/acl_token_update.go index 7a471fc15..1880c9c61 100644 --- a/command/acl_token_update.go +++ b/command/acl_token_update.go @@ -127,6 +127,6 @@ func (c *ACLTokenUpdateCommand) Run(args []string) int { } // Format the output - c.Ui.Output(formatKVACLToken(updatedToken)) + outputACLToken(c.Ui, updatedToken) return 0 } diff --git a/helper/funcs.go b/helper/funcs.go index ccb96613a..24aa0159e 100644 --- a/helper/funcs.go +++ b/helper/funcs.go @@ -718,3 +718,14 @@ func NewSafeTimer(duration time.Duration) (*time.Timer, StopFunc) { return t, cancel } + +// ConvertSlice takes the input slice and generates a new one using the +// supplied conversion function to covert the element. This is useful when +// converting a slice of strings to a slice of structs which wraps the string. +func ConvertSlice[A, B any](original []A, conversion func(a A) B) []B { + result := make([]B, len(original)) + for i, element := range original { + result[i] = conversion(element) + } + return result +} diff --git a/helper/funcs_test.go b/helper/funcs_test.go index 685430210..be77678d6 100644 --- a/helper/funcs_test.go +++ b/helper/funcs_test.go @@ -546,3 +546,27 @@ func Test_NewSafeTimer(t *testing.T) { <-timer.C }) } + +func Test_ConvertSlice(t *testing.T) { + t.Run("string wrapper", func(t *testing.T) { + + type wrapper struct{ id string } + input := []string{"foo", "bar", "bad", "had"} + cFn := func(id string) *wrapper { return &wrapper{id: id} } + + expectedOutput := []*wrapper{{id: "foo"}, {id: "bar"}, {id: "bad"}, {id: "had"}} + actualOutput := ConvertSlice(input, cFn) + require.ElementsMatch(t, expectedOutput, actualOutput) + }) + + t.Run("int wrapper", func(t *testing.T) { + + type wrapper struct{ id int } + input := []int{10, 13, 1987, 2020} + cFn := func(id int) *wrapper { return &wrapper{id: id} } + + expectedOutput := []*wrapper{{id: 10}, {id: 13}, {id: 1987}, {id: 2020}} + actualOutput := ConvertSlice(input, cFn) + require.ElementsMatch(t, expectedOutput, actualOutput) + }) +}