acl: sso auth methods cli commands (#15322)

This PR implements CLI commands to interact with SSO auth methods.

This PR is part of the SSO work captured under ☂️ ticket #13120.
This commit is contained in:
Piotr Kazmierczak 2022-11-28 10:51:45 +01:00 committed by GitHub
parent 726d419da1
commit db9316c4d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1264 additions and 0 deletions

103
command/acl_auth_method.go Normal file
View File

@ -0,0 +1,103 @@
package command
import (
"fmt"
"strings"
"github.com/hashicorp/nomad/api"
"github.com/mitchellh/cli"
)
// Ensure ACLAuthMethodCommand satisfies the cli.Command interface.
var _ cli.Command = &ACLAuthMethodCommand{}
// ACLAuthMethodCommand implements cli.Command.
type ACLAuthMethodCommand struct {
Meta
}
// Help satisfies the cli.Command Help function.
func (a *ACLAuthMethodCommand) Help() string {
helpText := `
Usage: nomad acl auth-method <subcommand> [options] [args]
This command groups subcommands for interacting with ACL auth methods.
Create an ACL auth method:
$ nomad acl auth-method create -name="name" -type="OIDC" -max-token-ttl="3600s"
List all ACL auth methods:
$ nomad acl auth-method list
Lookup a specific ACL auth method:
$ nomad acl auth-method info <acl_auth_method_name>
Update an ACL auth method:
$ nomad acl auth-method update -type="updated-type" <acl_auth_method_name>
Delete an ACL auth method:
$ nomad acl auth-method delete <acl_auth_method_name>
Please see the individual subcommand help for detailed usage information.
`
return strings.TrimSpace(helpText)
}
// Synopsis satisfies the cli.Command Synopsis function.
func (a *ACLAuthMethodCommand) Synopsis() string { return "Interact with ACL auth methods" }
// Name returns the name of this command.
func (a *ACLAuthMethodCommand) Name() string { return "acl auth-method" }
// Run satisfies the cli.Command Run function.
func (a *ACLAuthMethodCommand) Run(_ []string) int { return cli.RunResultHelp }
// formatAuthMethod formats and converts the ACL auth method API object into a
// string KV representation suitable for console output.
func formatAuthMethod(authMethod *api.ACLAuthMethod) string {
out := []string{
fmt.Sprintf("Name|%s", authMethod.Name),
fmt.Sprintf("Type|%s", authMethod.Type),
fmt.Sprintf("Locality|%s", authMethod.TokenLocality),
fmt.Sprintf("MaxTokenTTL|%s", authMethod.MaxTokenTTL.String()),
fmt.Sprintf("Default|%t", authMethod.Default),
}
if authMethod.Config != nil {
out = append(out, formatAuthMethodConfig(authMethod.Config)...)
}
out = append(out,
[]string{fmt.Sprintf("Create Index|%d", authMethod.CreateIndex),
fmt.Sprintf("Modify Index|%d", authMethod.ModifyIndex),
}...,
)
return formatKV(out)
}
func formatAuthMethodConfig(config *api.ACLAuthMethodConfig) []string {
return []string{
fmt.Sprintf("OIDC Discovery URL|%s", config.OIDCDiscoveryURL),
fmt.Sprintf("OIDC Client ID|%s", config.OIDCClientID),
fmt.Sprintf("OIDC Client Secret|%s", config.OIDCClientSecret),
fmt.Sprintf("Bound audiences|%s", strings.Join(config.BoundAudiences, ",")),
fmt.Sprintf("Allowed redirects URIs|%s", strings.Join(config.AllowedRedirectURIs, ",")),
fmt.Sprintf("Discovery CA pem|%s", strings.Join(config.DiscoveryCaPem, ",")),
fmt.Sprintf("Signing algorithms|%s", strings.Join(config.SigningAlgs, ",")),
fmt.Sprintf("Claim mappings|%s", formatMap(config.ClaimMappings)),
fmt.Sprintf("List claim mappings|%s", formatMap(config.ListClaimMappings)),
}
}
func formatMap(m map[string]string) string {
out := []string{}
for k, v := range m {
out = append(out, fmt.Sprintf("%s/%s", k, v))
}
return formatKV(out)
}

View File

@ -0,0 +1,179 @@
package command
import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/hashicorp/nomad/api"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"golang.org/x/exp/slices"
)
// Ensure ACLAuthMethodCreateCommand satisfies the cli.Command interface.
var _ cli.Command = &ACLAuthMethodCreateCommand{}
// ACLAuthMethodCreateCommand implements cli.Command.
type ACLAuthMethodCreateCommand struct {
Meta
name string
methodType string
tokenLocality string
maxTokenTTL time.Duration
isDefault bool
config string
testStdin io.Reader
}
// Help satisfies the cli.Command Help function.
func (a *ACLAuthMethodCreateCommand) Help() string {
helpText := `
Usage: nomad acl auth-method create [options]
Create is used to create new ACL auth methods. Use requires a management token.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + `
ACL Auth Method Create Options:
-name
Sets the human readable name for the ACL auth method. The name must be
between 1-128 characters and is a required parameter.
-type
Sets the type of the auth method. Currently the only supported type is
'OIDC'.
-max-token-ttl
Sets the duration of time all tokens created by this auth method should be
valid for.
-token-locality
Defines the kind of token that this auth method should produce. This can be
either 'local' or 'global'.
-default
Specifies whether this auth method should be treated as a default one in
case no auth method is explicitly specified for a login command.
-config
Auth method configuration in JSON format. May be prefixed with '@' to
indicate that the value is a file path to load the config from. '-' may
also be given to indicate that the config is available on stdin.
`
return strings.TrimSpace(helpText)
}
func (a *ACLAuthMethodCreateCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(a.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-name": complete.PredictAnything,
"-type": complete.PredictSet("OIDC"),
"-max-token-ttl": complete.PredictAnything,
"-token-locality": complete.PredictSet("local", "global"),
"-default": complete.PredictSet("true", "false"),
"-config": complete.PredictNothing,
})
}
func (a *ACLAuthMethodCreateCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
// Synopsis satisfies the cli.Command Synopsis function.
func (a *ACLAuthMethodCreateCommand) Synopsis() string { return "Create a new ACL auth method" }
// Name returns the name of this command.
func (a *ACLAuthMethodCreateCommand) Name() string { return "acl auth-method create" }
// Run satisfies the cli.Command Run function.
func (a *ACLAuthMethodCreateCommand) Run(args []string) int {
flags := a.Meta.FlagSet(a.Name(), FlagSetClient)
flags.Usage = func() { a.Ui.Output(a.Help()) }
flags.StringVar(&a.name, "name", "", "")
flags.StringVar(&a.methodType, "type", "", "")
flags.StringVar(&a.tokenLocality, "token-locality", "", "")
flags.DurationVar(&a.maxTokenTTL, "max-token-ttl", 0, "")
flags.BoolVar(&a.isDefault, "default", false, "")
flags.StringVar(&a.config, "config", "", "")
if err := flags.Parse(args); err != nil {
return 1
}
// Check that we got no arguments.
if len(flags.Args()) != 0 {
a.Ui.Error("This command takes no arguments")
a.Ui.Error(commandErrorText(a))
return 1
}
// Perform some basic validation
if a.name == "" {
a.Ui.Error("ACL auth method name must be specified using the -name flag")
return 1
}
if !slices.Contains([]string{"global", "local"}, a.tokenLocality) {
a.Ui.Error("Token locality must be set to either 'local' or 'global'")
return 1
}
if a.maxTokenTTL < 1 {
a.Ui.Error("Max token TTL must be set to a value between min and max TTL configured for the server.")
return 1
}
if strings.ToUpper(a.methodType) != "OIDC" {
a.Ui.Error("ACL auth method type must be set to 'OIDC'")
return 1
}
if len(a.config) == 0 {
a.Ui.Error("Must provide ACL auth method config in JSON format")
return 1
}
config, err := loadDataSource(a.config, a.testStdin)
if err != nil {
a.Ui.Error(fmt.Sprintf("Error loading configuration: %v", err))
return 1
}
configJSON := api.ACLAuthMethodConfig{}
err = json.Unmarshal([]byte(config), &configJSON)
if err != nil {
a.Ui.Error(fmt.Sprintf("Unable to parse config: %v", err))
return 1
}
// Set up the auth method with the passed parameters.
authMethod := api.ACLAuthMethod{
Name: a.name,
Type: strings.ToUpper(a.methodType),
TokenLocality: a.tokenLocality,
MaxTokenTTL: a.maxTokenTTL,
Default: a.isDefault,
Config: &configJSON,
}
// Get the HTTP client.
client, err := a.Meta.Client()
if err != nil {
a.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
// Create the auth method via the API.
_, err = client.ACLAuthMethods().Create(&authMethod, nil)
if err != nil {
a.Ui.Error(fmt.Sprintf("Error creating ACL auth method: %v", err))
return 1
}
a.Ui.Output(fmt.Sprintf("Created ACL auth method %s", a.name))
return 0
}

View File

@ -0,0 +1,99 @@
package command
import (
"encoding/json"
"fmt"
"os"
"testing"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/command/agent"
"github.com/hashicorp/nomad/testutil"
"github.com/mitchellh/cli"
"github.com/shoenig/test/must"
)
func TestACLAuthMethodCreateCommand_Run(t *testing.T) {
ci.Parallel(t)
// Build a test server with ACLs enabled.
srv, _, url := testServer(t, false, func(c *agent.Config) {
c.ACL.Enabled = true
})
defer srv.Shutdown()
// Wait for the server to start fully and ensure we have a bootstrap token.
testutil.WaitForLeader(t, srv.Agent.RPC)
rootACLToken := srv.RootToken
must.NotNil(t, rootACLToken)
ui := cli.NewMockUi()
cmd := &ACLAuthMethodCreateCommand{
Meta: Meta{
Ui: ui,
flagAddress: url,
},
}
// Test the basic validation on the command.
must.Eq(t, 1, cmd.Run([]string{"-address=" + url, "this-command-does-not-take-args"}))
must.StrContains(t, ui.ErrorWriter.String(), "This command takes no arguments")
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
must.Eq(t, 1, cmd.Run([]string{"-address=" + url}))
must.StrContains(t, ui.ErrorWriter.String(), "ACL auth method name must be specified using the -name flag")
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
must.Eq(t, 1, cmd.Run([]string{"-address=" + url, "-name=foobar", "-token-locality=global", "-max-token-ttl=3600s"}))
must.StrContains(t, ui.ErrorWriter.String(), "ACL auth method type must be set to 'OIDC'")
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
must.Eq(t, 1, cmd.Run([]string{"-address=" + url, "-name=foobar", "-type=OIDC", "-token-locality=global", "-max-token-ttl=3600s"}))
must.StrContains(t, ui.ErrorWriter.String(), "Must provide ACL auth method config in JSON format")
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
// Create an auth method
args := []string{
"-address=" + url, "-token=" + rootACLToken.SecretID, "-name=acl-auth-method-cli-test",
"-type=OIDC", "-token-locality=global", "-default=true", "-max-token-ttl=3600s",
"-config={\"OIDCDiscoveryURL\":\"http://example.com\"}",
}
must.Eq(t, 0, cmd.Run(args))
s := ui.OutputWriter.String()
must.StrContains(t, s, "acl-auth-method-cli-test")
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
// Create an auth method with a config from file
configFile, err := os.CreateTemp("", "config.json")
defer os.Remove(configFile.Name())
must.Nil(t, err)
conf := map[string]interface{}{"OIDCDiscoveryURL": "http://example.com"}
jsonData, err := json.Marshal(conf)
must.Nil(t, err)
_, err = configFile.Write(jsonData)
must.Nil(t, err)
args = []string{
"-address=" + url, "-token=" + rootACLToken.SecretID, "-name=acl-auth-method-cli-test",
"-type=OIDC", "-token-locality=global", "-default=true", "-max-token-ttl=3600s",
fmt.Sprintf("-config=@%s", configFile.Name()),
}
must.Eq(t, 0, cmd.Run(args))
s = ui.OutputWriter.String()
must.StrContains(t, s, "acl-auth-method-cli-test")
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
}

View File

@ -0,0 +1,86 @@
package command
import (
"fmt"
"strings"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
// Ensure ACLAuthMethodDeleteCommand satisfies the cli.Command interface.
var _ cli.Command = &ACLAuthMethodDeleteCommand{}
// ACLAuthMethodDeleteCommand implements cli.Command.
type ACLAuthMethodDeleteCommand struct {
Meta
}
// Help satisfies the cli.Command Help function.
func (a *ACLAuthMethodDeleteCommand) Help() string {
helpText := `
Usage: nomad acl auth-method delete <acl_method_name>
Delete is used to delete an existing ACL auth method. Use requires a
management token.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace)
return strings.TrimSpace(helpText)
}
func (a *ACLAuthMethodDeleteCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(a.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{})
}
func (a *ACLAuthMethodDeleteCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
// Synopsis satisfies the cli.Command Synopsis function.
func (a *ACLAuthMethodDeleteCommand) Synopsis() string { return "Delete an existing ACL auth method" }
// Name returns the name of this command.
func (a *ACLAuthMethodDeleteCommand) Name() string { return "acl auth-method delete" }
// Run satisfies the cli.Command Run function.
func (a *ACLAuthMethodDeleteCommand) Run(args []string) int {
flags := a.Meta.FlagSet(a.Name(), FlagSetClient)
flags.Usage = func() { a.Ui.Output(a.Help()) }
if err := flags.Parse(args); err != nil {
return 1
}
// Check that the last argument is the auth method name to delete.
if len(flags.Args()) != 1 {
a.Ui.Error("This command takes one argument: <acl_auth_method_name>")
a.Ui.Error(commandErrorText(a))
return 1
}
methodName := flags.Args()[0]
// Get the HTTP client.
client, err := a.Meta.Client()
if err != nil {
a.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
// Delete the specified method
_, err = client.ACLAuthMethods().Delete(methodName, nil)
if err != nil {
a.Ui.Error(fmt.Sprintf("Error deleting ACL auth method: %s", err))
return 1
}
// Give some feedback to indicate the deletion was successful.
a.Ui.Output(fmt.Sprintf("ACL auth method %s successfully deleted", methodName))
return 0
}

View File

@ -0,0 +1,52 @@
package command
import (
"fmt"
"testing"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/command/agent"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/mitchellh/cli"
"github.com/shoenig/test/must"
)
func TestACLAuthMethodDeleteCommand(t *testing.T) {
ci.Parallel(t)
config := func(c *agent.Config) {
c.ACL.Enabled = true
}
srv, _, url := testServer(t, true, config)
defer stopTestAgent(srv)
state := srv.Agent.Server().State()
// Bootstrap an initial ACL token
token := srv.RootToken
must.NotNil(t, token)
// Create a test auth method
method := &structs.ACLAuthMethod{
Name: "test-auth-method",
}
method.SetHash()
must.NoError(t, state.UpsertACLAuthMethods(1000, []*structs.ACLAuthMethod{method}))
ui := cli.NewMockUi()
cmd := &ACLAuthMethodDeleteCommand{Meta: Meta{Ui: ui, flagAddress: url}}
// Delete the method without a valid token fails
invalidToken := mock.ACLToken()
code := cmd.Run([]string{"-address=" + url, "-token=" + invalidToken.SecretID, method.Name})
must.One(t, code)
// Delete the method with a valid management token
code = cmd.Run([]string{"-address=" + url, "-token=" + token.SecretID, method.Name})
must.Zero(t, code)
// Check the output
out := ui.OutputWriter.String()
must.StrContains(t, out, fmt.Sprintf("%s successfully deleted", method.Name))
}

View File

@ -0,0 +1,119 @@
package command
import (
"fmt"
"strings"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
// Ensure ACLAuthMethodInfoCommand satisfies the cli.Command interface.
var _ cli.Command = &ACLAuthMethodInfoCommand{}
// ACLAuthMethodInfoCommand implements cli.Command.
type ACLAuthMethodInfoCommand struct {
Meta
json bool
tmpl string
}
// Help satisfies the cli.Command Help function.
func (a *ACLAuthMethodInfoCommand) Help() string {
helpText := `
Usage: nomad acl auth-method info [options] <acl_method_name>
Info is used to fetch information on an existing ACL auth method. Requires a
management token.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + `
ACL Info Options:
-json
Output the ACL role in a JSON format.
-t
Format and display the ACL role using a Go template.
`
return strings.TrimSpace(helpText)
}
func (a *ACLAuthMethodInfoCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(a.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
})
}
func (a *ACLAuthMethodInfoCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
// Synopsis satisfies the cli.Command Synopsis function.
func (a *ACLAuthMethodInfoCommand) Synopsis() string {
return "Fetch information on an existing ACL auth method"
}
// Name returns the name of this command.
func (a *ACLAuthMethodInfoCommand) Name() string { return "acl auth-method info" }
// Run satisfies the cli.Command Run function.
func (a *ACLAuthMethodInfoCommand) Run(args []string) int {
var json bool
var tmpl string
flags := a.Meta.FlagSet(a.Name(), FlagSetClient)
flags.Usage = func() { a.Ui.Output(a.Help()) }
flags.BoolVar(&json, "json", false, "")
flags.StringVar(&tmpl, "t", "", "")
if err := flags.Parse(args); err != nil {
return 1
}
// Check that we have exactly one argument.
if len(flags.Args()) != 1 {
a.Ui.Error("This command takes one argument: <acl_auth_method_name>")
a.Ui.Error(commandErrorText(a))
return 1
}
// Get the HTTP client.
client, err := a.Meta.Client()
if err != nil {
a.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
methodName := flags.Args()[0]
method, _, apiErr := client.ACLAuthMethods().Get(methodName, nil)
// Handle any error from the API.
if apiErr != nil {
a.Ui.Error(fmt.Sprintf("Error reading ACL auth method: %s", apiErr))
return 1
}
if json || len(tmpl) > 0 {
out, err := Format(json, tmpl, method)
if err != nil {
a.Ui.Error(err.Error())
return 1
}
a.Ui.Output(out)
return 0
}
// Format the output.
a.Ui.Output(formatAuthMethod(method))
return 0
}

View File

@ -0,0 +1,54 @@
package command
import (
"testing"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/command/agent"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/mitchellh/cli"
"github.com/shoenig/test/must"
)
func TestACLAuthMethodInfoCommand(t *testing.T) {
ci.Parallel(t)
config := func(c *agent.Config) {
c.ACL.Enabled = true
}
srv, _, url := testServer(t, true, config)
state := srv.Agent.Server().State()
defer stopTestAgent(srv)
// Bootstrap an initial ACL token
token := srv.RootToken
must.NotNil(t, token)
// Create a test auth method
method := &structs.ACLAuthMethod{
Name: "test-auth-method",
Config: &structs.ACLAuthMethodConfig{
OIDCDiscoveryURL: "http://example.com",
},
}
method.SetHash()
must.NoError(t, state.UpsertACLAuthMethods(1000, []*structs.ACLAuthMethod{method}))
ui := cli.NewMockUi()
cmd := &ACLAuthMethodInfoCommand{Meta: Meta{Ui: ui, flagAddress: url}}
// Attempt to get info without a valid management token
invalidToken := mock.ACLToken()
code := cmd.Run([]string{"-address=" + url, "-token=" + invalidToken.SecretID, method.Name})
must.One(t, code)
// Get info with a valid management token
code = cmd.Run([]string{"-address=" + url, "-token=" + token.SecretID, method.Name})
must.Zero(t, code)
// Check the output
out := ui.OutputWriter.String()
must.StrContains(t, out, method.Name)
}

View File

@ -0,0 +1,124 @@
package command
import (
"fmt"
"strings"
"github.com/hashicorp/nomad/api"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
// Ensure ACLAuthMethodListCommand satisfies the cli.Command interface.
var _ cli.Command = &ACLAuthMethodListCommand{}
// ACLAuthMethodListCommand implements cli.Command.
type ACLAuthMethodListCommand struct {
Meta
}
// Help satisfies the cli.Command Help function.
func (a *ACLAuthMethodListCommand) Help() string {
helpText := `
Usage: nomad acl auth-method list [options]
List is used to list existing ACL auth methods.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + `
ACL List Options:
-json
Output the ACL auth methods in a JSON format.
-t
Format and display the ACL auth methods using a Go template.
`
return strings.TrimSpace(helpText)
}
func (a *ACLAuthMethodListCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(a.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
})
}
func (a *ACLAuthMethodListCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
// Synopsis satisfies the cli.Command Synopsis function.
func (a *ACLAuthMethodListCommand) Synopsis() string { return "List ACL auth methods" }
// Name returns the name of this command.
func (a *ACLAuthMethodListCommand) Name() string { return "acl auth-method list" }
// Run satisfies the cli.Command Run function.
func (a *ACLAuthMethodListCommand) Run(args []string) int {
var json bool
var tmpl string
flags := a.Meta.FlagSet(a.Name(), FlagSetClient)
flags.Usage = func() { a.Ui.Output(a.Help()) }
flags.BoolVar(&json, "json", false, "")
flags.StringVar(&tmpl, "t", "", "")
if err := flags.Parse(args); err != nil {
return 1
}
// Check that we got no arguments
if len(flags.Args()) != 0 {
a.Ui.Error("This command takes no arguments")
a.Ui.Error(commandErrorText(a))
return 1
}
// Get the HTTP client
client, err := a.Meta.Client()
if err != nil {
a.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
// Fetch info on the method
methods, _, err := client.ACLAuthMethods().List(nil)
if err != nil {
a.Ui.Error(fmt.Sprintf("Error listing ACL auth methods: %s", err))
return 1
}
if json || len(tmpl) > 0 {
out, err := Format(json, tmpl, methods)
if err != nil {
a.Ui.Error(err.Error())
return 1
}
a.Ui.Output(out)
return 0
}
a.Ui.Output(formatAuthMethods(methods))
return 0
}
func formatAuthMethods(methods []*api.ACLAuthMethodListStub) string {
if len(methods) == 0 {
return "No ACL auth methods found"
}
output := make([]string, 0, len(methods)+1)
output = append(output, "Name|Default")
for _, method := range methods {
output = append(output, fmt.Sprintf(
"%s|%v",
method.Name, method.Default))
}
return formatList(output)
}

View File

@ -0,0 +1,61 @@
package command
import (
"testing"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/command/agent"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/mitchellh/cli"
"github.com/shoenig/test/must"
)
func TestACLAuthMethodListCommand(t *testing.T) {
ci.Parallel(t)
config := func(c *agent.Config) {
c.ACL.Enabled = true
}
srv, _, url := testServer(t, true, config)
state := srv.Agent.Server().State()
defer stopTestAgent(srv)
// Bootstrap an initial ACL token
token := srv.RootToken
must.NotNil(t, token)
// Create a test auth method
method := &structs.ACLAuthMethod{
Name: "test-auth-method",
Config: &structs.ACLAuthMethodConfig{
OIDCDiscoveryURL: "http://example.com",
},
}
method.SetHash()
must.NoError(t, state.UpsertACLAuthMethods(1000, []*structs.ACLAuthMethod{method}))
ui := cli.NewMockUi()
cmd := &ACLAuthMethodListCommand{Meta: Meta{Ui: ui, flagAddress: url}}
// Attempt to list auth methods without a valid management token
invalidToken := mock.ACLToken()
code := cmd.Run([]string{"-address=" + url, "-token=" + invalidToken.SecretID})
must.One(t, code)
// List with a valid management token
code = cmd.Run([]string{"-address=" + url, "-token=" + token.SecretID})
must.Zero(t, code)
// Check the output
out := ui.OutputWriter.String()
must.StrContains(t, out, method.Name)
// List json
must.Zero(t, cmd.Run([]string{"-address=" + url, "-token=" + token.SecretID, "-json"}))
out = ui.OutputWriter.String()
must.StrContains(t, out, "CreateIndex")
ui.OutputWriter.Reset()
}

View File

@ -0,0 +1,206 @@
package command
import (
"encoding/json"
"flag"
"fmt"
"io"
"strings"
"time"
"github.com/hashicorp/nomad/api"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"golang.org/x/exp/slices"
)
// Ensure ACLAuthMethodUpdateCommand satisfies the cli.Command interface.
var _ cli.Command = &ACLAuthMethodUpdateCommand{}
// ACLAuthMethodUpdateCommand implements cli.Command.
type ACLAuthMethodUpdateCommand struct {
Meta
methodType string
tokenLocality string
maxTokenTTL time.Duration
isDefault bool
config string
testStdin io.Reader
}
// Help satisfies the cli.Command Help function.
func (a *ACLAuthMethodUpdateCommand) Help() string {
helpText := `
Usage: nomad acl auth-method update [options] <acl_auth_method_name>
Update is used to update ACL auth methods. Use requires a management token.
General Options:
` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + `
ACL Auth Method Update Options:
-type
Updates the type of the auth method. Currently the only supported type is
'OIDC'.
-max-token-ttl
Updates the duration of time all tokens created by this auth method should be
valid for.
-token-locality
Updates the kind of token that this auth method should produce. This can be
either 'local' or 'global'.
-default
Specifies whether this auth method should be treated as a default one in
case no auth method is explicitly specified for a login command.
-config
Updates auth method configuration (in JSON format). May be prefixed with
'@' to indicate that the value is a file path to load the config from. '-'
may also be given to indicate that the config is available on stdin.
`
return strings.TrimSpace(helpText)
}
func (a *ACLAuthMethodUpdateCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(a.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-type": complete.PredictSet("OIDC"),
"-max-token-ttl": complete.PredictAnything,
"-token-locality": complete.PredictSet("local", "global"),
"-default": complete.PredictSet("true", "false"),
"-config": complete.PredictNothing,
})
}
func (a *ACLAuthMethodUpdateCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
// Synopsis satisfies the cli.Command Synopsis function.
func (a *ACLAuthMethodUpdateCommand) Synopsis() string { return "Update an existing ACL auth method" }
// Name returns the name of this command.
func (*ACLAuthMethodUpdateCommand) Name() string { return "acl auth-method update" }
// Run satisfies the cli.Command Run function.
func (a *ACLAuthMethodUpdateCommand) Run(args []string) int {
flags := a.Meta.FlagSet(a.Name(), FlagSetClient)
flags.Usage = func() { a.Ui.Output(a.Help()) }
flags.StringVar(&a.methodType, "type", "", "")
flags.StringVar(&a.tokenLocality, "token-locality", "", "")
flags.DurationVar(&a.maxTokenTTL, "max-token-ttl", 0, "")
flags.StringVar(&a.config, "config", "", "")
flags.BoolVar(&a.isDefault, "default", false, "")
if err := flags.Parse(args); err != nil {
return 1
}
// Check that the last argument is the auth method name to delete.
if len(flags.Args()) != 1 {
a.Ui.Error("This command takes one argument: <acl_auth_method_name>")
a.Ui.Error(commandErrorText(a))
return 1
}
originalMethodName := flags.Args()[0]
// Get the HTTP client.
client, err := a.Meta.Client()
if err != nil {
a.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
// Check if the method we want to update exists
originalMethod, _, err := client.ACLAuthMethods().Get(originalMethodName, nil)
if err != nil {
a.Ui.Error(fmt.Sprintf("Error when retrieving ACL auth method: %v", err))
return 1
}
// Check if any command-specific flags were set
setFlags := []string{}
for _, f := range []string{"type", "token-locality", "max-token-ttl", "config", "default"} {
if flagPassed(flags, f) {
setFlags = append(setFlags, f)
}
}
if len(setFlags) == 0 {
a.Ui.Error("Please provide at least one flag to update the ACL auth method")
return 1
}
updatedMethod := *originalMethod
if slices.Contains(setFlags, "token-locality") {
if !slices.Contains([]string{"global", "local"}, a.tokenLocality) {
a.Ui.Error("Token locality must be set to either 'local' or 'global'")
return 1
}
updatedMethod.TokenLocality = a.tokenLocality
}
if slices.Contains(setFlags, "type") {
if strings.ToLower(a.methodType) != "oidc" {
a.Ui.Error("ACL auth method type must be set to 'OIDC'")
return 1
}
updatedMethod.Type = a.methodType
}
if slices.Contains(setFlags, "max-token-ttl") {
if a.maxTokenTTL < 1 {
a.Ui.Error("Max token TTL must be set to a value between min and max TTL configured for the server.")
return 1
}
updatedMethod.MaxTokenTTL = a.maxTokenTTL
}
if slices.Contains(setFlags, "default") {
updatedMethod.Default = a.isDefault
}
if len(a.config) != 0 {
config, err := loadDataSource(a.config, a.testStdin)
if err != nil {
a.Ui.Error(fmt.Sprintf("Error loading configuration: %v", err))
return 1
}
configJSON := api.ACLAuthMethodConfig{}
err = json.Unmarshal([]byte(config), &configJSON)
if err != nil {
a.Ui.Error(fmt.Sprintf("Unable to parse config: %v", err))
return 1
}
updatedMethod.Config = &configJSON
}
// Update the auth method via the API.
_, err = client.ACLAuthMethods().Update(&updatedMethod, nil)
if err != nil {
a.Ui.Error(fmt.Sprintf("Error updating ACL auth method: %v", err))
return 1
}
a.Ui.Output(fmt.Sprintf("Updated ACL auth method %s", originalMethodName))
return 0
}
func flagPassed(flags *flag.FlagSet, name string) bool {
found := false
flags.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}

View File

@ -0,0 +1,111 @@
package command
import (
"encoding/json"
"fmt"
"os"
"testing"
"time"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/command/agent"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/testutil"
"github.com/mitchellh/cli"
"github.com/shoenig/test/must"
)
func TestACLAuthMethodUpdateCommand_Run(t *testing.T) {
ci.Parallel(t)
// Build a test server with ACLs enabled.
srv, _, url := testServer(t, false, func(c *agent.Config) {
c.ACL.Enabled = true
})
defer srv.Shutdown()
// Wait for the server to start fully and ensure we have a bootstrap token.
testutil.WaitForLeader(t, srv.Agent.RPC)
rootACLToken := srv.RootToken
must.NotNil(t, rootACLToken)
ui := cli.NewMockUi()
cmd := &ACLAuthMethodUpdateCommand{
Meta: Meta{
Ui: ui,
flagAddress: url,
},
}
// Try calling the command without setting the method name argument
must.One(t, cmd.Run([]string{"-address=" + url}))
must.StrContains(t, ui.ErrorWriter.String(), "This command takes one argument")
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
// Try calling the command with a method name that doesn't exist
code := cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID, "catch-me-if-you-can"})
must.One(t, code)
must.StrContains(t, ui.ErrorWriter.String(), "ACL auth-method not found")
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
// Create a test auth method
ttl, _ := time.ParseDuration("3600s")
method := &structs.ACLAuthMethod{
Name: "test-auth-method",
Type: "OIDC",
MaxTokenTTL: ttl,
TokenLocality: "local",
Config: &structs.ACLAuthMethodConfig{
OIDCDiscoveryURL: "http://example.com",
},
}
method.SetHash()
must.NoError(t, srv.Agent.Server().State().UpsertACLAuthMethods(1000, []*structs.ACLAuthMethod{method}))
// Try an update without setting any parameters to update.
code = cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID, method.Name})
must.One(t, code)
must.StrContains(t, ui.ErrorWriter.String(), "Please provide at least one flag to update the ACL auth method")
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
// Update the token locality
code = cmd.Run([]string{
"-address=" + url, "-token=" + rootACLToken.SecretID, "-token-locality=global", method.Name})
must.Zero(t, code)
s := ui.OutputWriter.String()
must.StrContains(t, s, method.Name)
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
// Update an auth method with a config from file
configFile, err := os.CreateTemp("", "config.json")
defer os.Remove(configFile.Name())
must.Nil(t, err)
conf := map[string]interface{}{"OIDCDiscoveryURL": "http://example.com"}
jsonData, err := json.Marshal(conf)
must.Nil(t, err)
_, err = configFile.Write(jsonData)
must.Nil(t, err)
code = cmd.Run([]string{
"-address=" + url,
"-token=" + rootACLToken.SecretID,
fmt.Sprintf("-config=@%s", configFile.Name()),
method.Name,
})
must.Zero(t, code)
s = ui.OutputWriter.String()
must.StrContains(t, s, method.Name)
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
}

View File

@ -77,6 +77,36 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
Meta: meta,
}, nil
},
"acl auth-method": func() (cli.Command, error) {
return &ACLAuthMethodCommand{
Meta: meta,
}, nil
},
"acl auth-method create": func() (cli.Command, error) {
return &ACLAuthMethodCreateCommand{
Meta: meta,
}, nil
},
"acl auth-method delete": func() (cli.Command, error) {
return &ACLAuthMethodDeleteCommand{
Meta: meta,
}, nil
},
"acl auth-method info": func() (cli.Command, error) {
return &ACLAuthMethodInfoCommand{
Meta: meta,
}, nil
},
"acl auth-method list": func() (cli.Command, error) {
return &ACLAuthMethodListCommand{
Meta: meta,
}, nil
},
"acl auth-method update": func() (cli.Command, error) {
return &ACLAuthMethodUpdateCommand{
Meta: meta,
}, nil
},
"acl bootstrap": func() (cli.Command, error) {
return &ACLBootstrapCommand{
Meta: meta,

View File

@ -600,3 +600,43 @@ func (w *uiErrorWriter) Close() error {
}
return nil
}
func loadDataSource(data string, testStdin io.Reader) (string, error) {
// Handle empty quoted shell parameters
if len(data) == 0 {
return "", nil
}
switch data[0] {
case '@':
return loadFromFile(data[1:])
case '-':
if len(data) > 1 {
return data, nil
}
return loadFromStdin(testStdin)
default:
return data, nil
}
}
func loadFromFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("Failed to read file: %v", err)
}
return string(data), nil
}
func loadFromStdin(testStdin io.Reader) (string, error) {
var stdin io.Reader = os.Stdin
if testStdin != nil {
stdin = testStdin
}
var b bytes.Buffer
if _, err := io.Copy(&b, stdin); err != nil {
return "", fmt.Errorf("Failed to read stdin: %v", err)
}
return b.String(), nil
}