From 4bf27d743d3594700e356adf82f14d7b29d7a243 Mon Sep 17 00:00:00 2001 From: Lance Haig Date: Fri, 3 Jun 2022 13:37:24 +0200 Subject: [PATCH] Allow Operator Generated bootstrap token (#12520) --- .changelog/12520.txt | 3 + .gitignore | 2 + api/acl.go | 23 ++++++ api/acl_test.go | 30 +++++++ command/acl_bootstrap.go | 31 +++++++- command/acl_bootstrap_test.go | 79 +++++++++++++++++++ command/agent/acl_endpoint.go | 10 ++- command/agent/acl_endpoint_test.go | 43 ++++++++++ nomad/acl_endpoint.go | 12 +++ nomad/acl_endpoint_test.go | 40 ++++++++++ nomad/structs/structs.go | 5 +- website/content/api-docs/acl-tokens.mdx | 38 ++++++++- .../content/docs/commands/acl/bootstrap.mdx | 45 ++++++++++- 13 files changed, 351 insertions(+), 10 deletions(-) create mode 100644 .changelog/12520.txt diff --git a/.changelog/12520.txt b/.changelog/12520.txt new file mode 100644 index 000000000..80ce4c84d --- /dev/null +++ b/.changelog/12520.txt @@ -0,0 +1,3 @@ +```release-note:improvement +bootstrap: Added option to allow for an operator generated bootstrap token to be passed to the `acl bootstrap` command +``` \ No newline at end of file diff --git a/.gitignore b/.gitignore index 92a34e711..0b0d15fcb 100644 --- a/.gitignore +++ b/.gitignore @@ -77,8 +77,10 @@ GNUMakefile.local rkt-* +# Common editor config ./idea *.iml +.vscode # UI rules diff --git a/api/acl.go b/api/acl.go index 0652e409c..a964b01e0 100644 --- a/api/acl.go +++ b/api/acl.go @@ -72,6 +72,7 @@ func (c *Client) ACLTokens() *ACLTokens { return &ACLTokens{client: c} } +// DEPRECATED: will be removed in Nomad 1.5.0 // Bootstrap is used to get the initial bootstrap token func (a *ACLTokens) Bootstrap(q *WriteOptions) (*ACLToken, *WriteMeta, error) { var resp ACLToken @@ -82,6 +83,23 @@ func (a *ACLTokens) Bootstrap(q *WriteOptions) (*ACLToken, *WriteMeta, error) { return &resp, wm, nil } +// BootstrapOpts is used to get the initial bootstrap token or pass in the one that was provided in the API +func (a *ACLTokens) BootstrapOpts(btoken string, q *WriteOptions) (*ACLToken, *WriteMeta, error) { + if q == nil { + q = &WriteOptions{} + } + req := &BootstrapRequest{ + BootstrapSecret: btoken, + } + + var resp ACLToken + wm, err := a.client.write("/v1/acl/bootstrap", req, &resp, q) + if err != nil { + return nil, nil, err + } + return &resp, wm, nil +} + // List is used to dump all of the tokens. func (a *ACLTokens) List(q *QueryOptions) ([]*ACLTokenListStub, *QueryMeta, error) { var resp []*ACLTokenListStub @@ -244,3 +262,8 @@ type OneTimeTokenExchangeRequest struct { type OneTimeTokenExchangeResponse struct { Token *ACLToken } + +// BootstrapRequest is used for when operators provide an ACL Bootstrap Token +type BootstrapRequest struct { + BootstrapSecret string +} diff --git a/api/acl_test.go b/api/acl_test.go index 6e32df71c..0b7dbc025 100644 --- a/api/acl_test.go +++ b/api/acl_test.go @@ -269,3 +269,33 @@ func TestACL_OneTimeToken(t *testing.T) { assert.NotNil(t, out3) assert.Equal(t, out3.AccessorID, out.AccessorID) } + +func TestACLTokens_BootstrapInvalidToken(t *testing.T) { + testutil.Parallel(t) + c, s := makeClient(t, nil, func(c *testutil.TestServerConfig) { + c.ACL.Enabled = true + }) + defer s.Stop() + at := c.ACLTokens() + + bootkn := "badtoken" + // Bootstrap with invalid token + _, _, err := at.BootstrapOpts(bootkn, nil) + assert.EqualError(t, err, "Unexpected response code: 400 (invalid acl token)") +} + +func TestACLTokens_BootstrapValidToken(t *testing.T) { + testutil.Parallel(t) + c, s := makeClient(t, nil, func(c *testutil.TestServerConfig) { + c.ACL.Enabled = true + }) + defer s.Stop() + at := c.ACLTokens() + + bootkn := "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a" + // Bootstrap with Valid token + out, wm, err := at.BootstrapOpts(bootkn, nil) + assert.NoError(t, err) + assertWriteMeta(t, wm) + assert.Equal(t, bootkn, out.SecretID) +} diff --git a/command/acl_bootstrap.go b/command/acl_bootstrap.go index 85dbc69bf..f8970f938 100644 --- a/command/acl_bootstrap.go +++ b/command/acl_bootstrap.go @@ -2,6 +2,8 @@ package command import ( "fmt" + "io/ioutil" + "os" "strings" "github.com/hashicorp/nomad/api" @@ -57,6 +59,7 @@ func (c *ACLBootstrapCommand) Run(args []string) int { var ( json bool tmpl string + file string ) flags := c.Meta.FlagSet(c.Name(), FlagSetClient) @@ -69,12 +72,34 @@ func (c *ACLBootstrapCommand) Run(args []string) int { // Check that we got no arguments args = flags.Args() - if l := len(args); l != 0 { - c.Ui.Error("This command takes no arguments") + if l := len(args); l < 0 || l > 1 { + c.Ui.Error("This command takes up to one argument") c.Ui.Error(commandErrorText(c)) return 1 } + var terminalToken []byte + var err error + + if len(args) == 1 { + switch args[0] { + case "": + terminalToken = []byte{} + case "-": + terminalToken, err = ioutil.ReadAll(os.Stdin) + default: + file = args[0] + terminalToken, err = ioutil.ReadFile(file) + } + if err != nil { + c.Ui.Error(fmt.Sprintf("Error reading provided token: %v", err)) + return 1 + } + } + + // Remove newline from the token if it was passed by stdin + boottoken := strings.TrimSuffix(string(terminalToken), "\n") + // Get the HTTP client client, err := c.Meta.Client() if err != nil { @@ -83,7 +108,7 @@ func (c *ACLBootstrapCommand) Run(args []string) int { } // Get the bootstrap token - token, _, err := client.ACLTokens().Bootstrap(nil) + token, _, err := client.ACLTokens().BootstrapOpts(boottoken, nil) if err != nil { c.Ui.Error(fmt.Sprintf("Error bootstrapping: %s", err)) return 1 diff --git a/command/acl_bootstrap_test.go b/command/acl_bootstrap_test.go index c972f4488..c91588b23 100644 --- a/command/acl_bootstrap_test.go +++ b/command/acl_bootstrap_test.go @@ -1,12 +1,16 @@ package command import ( + "io/ioutil" + "os" "testing" "github.com/hashicorp/nomad/ci" "github.com/hashicorp/nomad/command/agent" + "github.com/hashicorp/nomad/nomad/mock" "github.com/mitchellh/cli" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestACLBootstrapCommand(t *testing.T) { @@ -76,3 +80,78 @@ func TestACLBootstrapCommand_NonACLServer(t *testing.T) { out := ui.OutputWriter.String() assert.NotContains(out, "Secret ID") } + +// Attempting to bootstrap the server with an operator provided token in a file should +// return the same token in the result. +func TestACLBootstrapCommand_WithOperatorFileBootstrapToken(t *testing.T) { + ci.Parallel(t) + // create a acl-enabled server without bootstrapping the token + config := func(c *agent.Config) { + c.ACL.Enabled = true + c.ACL.PolicyTTL = 0 + } + + // create a valid token + mockToken := mock.ACLToken() + + // Create temp file + f, err := ioutil.TempFile("", "nomad-token.token") + assert.Nil(t, err) + defer os.Remove(f.Name()) + + // Write the token to the file + err = ioutil.WriteFile(f.Name(), []byte(mockToken.SecretID), 0700) + assert.Nil(t, err) + + srv, _, url := testServer(t, true, config) + defer srv.Shutdown() + + require.Nil(t, srv.RootToken) + + ui := cli.NewMockUi() + cmd := &ACLBootstrapCommand{Meta: Meta{Ui: ui, flagAddress: url}} + + code := cmd.Run([]string{"-address=" + url, f.Name()}) + assert.Equal(t, 0, code) + + out := ui.OutputWriter.String() + assert.Contains(t, out, mockToken.SecretID) +} + +// Attempting to bootstrap the server with an invalid operator provided token in a file should +// fail. +func TestACLBootstrapCommand_WithBadOperatorFileBootstrapToken(t *testing.T) { + ci.Parallel(t) + + // create a acl-enabled server without bootstrapping the token + config := func(c *agent.Config) { + c.ACL.Enabled = true + c.ACL.PolicyTTL = 0 + } + + // create a invalid token + invalidToken := "invalid-token" + + // Create temp file + f, err := ioutil.TempFile("", "nomad-token.token") + assert.Nil(t, err) + defer os.Remove(f.Name()) + + // Write the token to the file + err = ioutil.WriteFile(f.Name(), []byte(invalidToken), 0700) + assert.Nil(t, err) + + srv, _, url := testServer(t, true, config) + defer srv.Shutdown() + + assert.Nil(t, srv.RootToken) + + ui := cli.NewMockUi() + cmd := &ACLBootstrapCommand{Meta: Meta{Ui: ui, flagAddress: url}} + + code := cmd.Run([]string{"-address=" + url, f.Name()}) + assert.Equal(t, 1, code) + + out := ui.OutputWriter.String() + assert.NotContains(t, out, invalidToken) +} diff --git a/command/agent/acl_endpoint.go b/command/agent/acl_endpoint.go index c89e6fe2a..df3f56851 100644 --- a/command/agent/acl_endpoint.go +++ b/command/agent/acl_endpoint.go @@ -138,8 +138,14 @@ func (s *HTTPServer) ACLTokenBootstrap(resp http.ResponseWriter, req *http.Reque return nil, CodedError(405, ErrInvalidMethod) } - // Format the request - args := structs.ACLTokenBootstrapRequest{} + var args structs.ACLTokenBootstrapRequest + + if req.ContentLength != 0 { + if err := decodeBody(req, &args); err != nil { + return nil, CodedError(400, err.Error()) + } + } + s.parseWriteRequest(req, &args.WriteRequest) var out structs.ACLTokenUpsertResponse diff --git a/command/agent/acl_endpoint_test.go b/command/agent/acl_endpoint_test.go index 64f37a333..0e52a0899 100644 --- a/command/agent/acl_endpoint_test.go +++ b/command/agent/acl_endpoint_test.go @@ -221,6 +221,49 @@ func TestHTTP_ACLTokenBootstrap(t *testing.T) { }) } +func TestHTTP_ACLTokenBootstrapOperator(t *testing.T) { + ci.Parallel(t) + conf := func(c *Config) { + c.ACL.Enabled = true + c.ACL.PolicyTTL = 0 // Special flag to disable auto-bootstrap + } + httpTest(t, conf, func(s *TestAgent) { + // Provide token + args := structs.ACLTokenBootstrapRequest{ + BootstrapSecret: "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a", + } + + buf := encodeReq(args) + + // Make the HTTP request + req, err := http.NewRequest("PUT", "/v1/acl/bootstrap", buf) + if err != nil { + t.Fatalf("err: %v", err) + } + + // Since we're not actually writing this HTTP request, we have + // to manually set ContentLength + req.ContentLength = -1 + + respW := httptest.NewRecorder() + // Make the request + obj, err := s.Server.ACLTokenBootstrap(respW, req) + if err != nil { + t.Fatalf("err: %v", err) + } + + // Check for the index + if respW.Result().Header.Get("X-Nomad-Index") == "" { + t.Fatalf("missing index") + } + + // Check the output + n := obj.(*structs.ACLToken) + assert.NotNil(t, n) + assert.Equal(t, args.BootstrapSecret, n.SecretID) + }) +} + func TestHTTP_ACLTokenList(t *testing.T) { ci.Parallel(t) httpACLTest(t, nil, func(s *TestAgent) { diff --git a/nomad/acl_endpoint.go b/nomad/acl_endpoint.go index c5df70e29..23a9e9913 100644 --- a/nomad/acl_endpoint.go +++ b/nomad/acl_endpoint.go @@ -13,6 +13,7 @@ import ( log "github.com/hashicorp/go-hclog" memdb "github.com/hashicorp/go-memdb" policy "github.com/hashicorp/nomad/acl" + "github.com/hashicorp/nomad/helper" "github.com/hashicorp/nomad/helper/uuid" "github.com/hashicorp/nomad/nomad/state" "github.com/hashicorp/nomad/nomad/state/paginator" @@ -353,6 +354,7 @@ func (a *ACL) Bootstrap(args *structs.ACLTokenBootstrapRequest, reply *structs.A return aclDisabled } args.Region = a.srv.config.AuthoritativeRegion + providedTokenID := args.BootstrapSecret if done, err := a.srv.forward("ACL.Bootstrap", args, args, reply); done { return err @@ -396,6 +398,16 @@ func (a *ACL) Bootstrap(args *structs.ACLTokenBootstrapRequest, reply *structs.A Global: true, CreateTime: time.Now().UTC(), } + + // if a token has been passed in from the API overwrite the generated one. + if providedTokenID != "" { + if helper.IsUUID(providedTokenID) { + args.Token.SecretID = providedTokenID + } else { + return structs.NewErrRPCCodedf(400, "invalid acl token") + } + } + args.Token.SetHash() // Update via Raft diff --git a/nomad/acl_endpoint_test.go b/nomad/acl_endpoint_test.go index 2e17d0204..5a9785d40 100644 --- a/nomad/acl_endpoint_test.go +++ b/nomad/acl_endpoint_test.go @@ -1349,6 +1349,46 @@ func TestACLEndpoint_Bootstrap(t *testing.T) { assert.Equal(t, created, out) } +func TestACLEndpoint_BootstrapOperator(t *testing.T) { + ci.Parallel(t) + s1, cleanupS1 := TestServer(t, func(c *Config) { + c.ACLEnabled = true + }) + defer cleanupS1() + codec := rpcClient(t, s1) + testutil.WaitForLeader(t, s1.RPC) + + // Lookup the tokens + req := &structs.ACLTokenBootstrapRequest{ + WriteRequest: structs.WriteRequest{Region: "global"}, + BootstrapSecret: "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a", + } + var resp structs.ACLTokenUpsertResponse + if err := msgpackrpc.CallWithCodec(codec, "ACL.Bootstrap", req, &resp); err != nil { + t.Fatalf("err: %v", err) + } + assert.NotEqual(t, uint64(0), resp.Index) + assert.NotNil(t, resp.Tokens[0]) + + // Get the token out from the response + created := resp.Tokens[0] + assert.NotEqual(t, "", created.AccessorID) + assert.NotEqual(t, "", created.SecretID) + assert.NotEqual(t, time.Time{}, created.CreateTime) + assert.Equal(t, structs.ACLManagementToken, created.Type) + assert.Equal(t, "Bootstrap Token", created.Name) + assert.Equal(t, true, created.Global) + + // Check we created the token + out, err := s1.fsm.State().ACLTokenByAccessorID(nil, created.AccessorID) + assert.Nil(t, err) + assert.Equal(t, created, out) + // Check we have the correct operator token + tokenout, err := s1.fsm.State().ACLTokenBySecretID(nil, created.SecretID) + assert.Nil(t, err) + assert.Equal(t, created, tokenout) +} + func TestACLEndpoint_Bootstrap_Reset(t *testing.T) { ci.Parallel(t) dir := t.TempDir() diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index 07dd6dca8..b376ebfaa 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -11909,8 +11909,9 @@ type ACLTokenDeleteRequest struct { // ACLTokenBootstrapRequest is used to bootstrap ACLs type ACLTokenBootstrapRequest struct { - Token *ACLToken // Not client specifiable - ResetIndex uint64 // Reset index is used to clear the bootstrap token + Token *ACLToken // Not client specifiable + ResetIndex uint64 // Reset index is used to clear the bootstrap token + BootstrapSecret string WriteRequest } diff --git a/website/content/api-docs/acl-tokens.mdx b/website/content/api-docs/acl-tokens.mdx index 4c08ade21..095fd66ca 100644 --- a/website/content/api-docs/acl-tokens.mdx +++ b/website/content/api-docs/acl-tokens.mdx @@ -11,7 +11,10 @@ For more details about ACLs, please see the [ACL Guide](https://learn.hashicorp. ## Bootstrap Token -This endpoint is used to bootstrap the ACL system and provide the initial management token. +This endpoint is used to bootstrap the ACL system and provide the initial management token. +An operator created token can be provided in the body of the request to bootstrap the cluster +if required. If no header is provided the cluster will return a generated management token. +The provided token should be presented in a UUID format. This request is always forwarded to the authoritative region. It can only be invoked once until a [bootstrap reset](https://learn.hashicorp.com/tutorials/nomad/access-control-bootstrap#re-bootstrap-acl-system) is performed. @@ -51,6 +54,39 @@ $ curl \ } ``` +### Sample Operator Payload + +```json +{ + "BootstrapSecret": "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a" +} +``` + +### Sample Request With Operator Token + +```shell-session +$ curl \ + --request POST \ + --data @root-token.json \ + https://localhost:4646/v1/acl/bootstrap +``` + +### Sample Response With Operator Token + +```json +{ + "AccessorID": "b780e702-98ce-521f-2e5f-c6b87de05b24", + "SecretID": "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a", + "Name": "Bootstrap Token", + "Type": "management", + "Policies": null, + "Global": true, + "CreateTime": "2017-08-23T22:47:14.695408057Z", + "CreateIndex": 7, + "ModifyIndex": 7 +} +``` + ## List Tokens This endpoint lists all ACL tokens. This lists the local tokens and the global diff --git a/website/content/docs/commands/acl/bootstrap.mdx b/website/content/docs/commands/acl/bootstrap.mdx index cdc04845a..f797d33d2 100644 --- a/website/content/docs/commands/acl/bootstrap.mdx +++ b/website/content/docs/commands/acl/bootstrap.mdx @@ -15,7 +15,11 @@ The `acl bootstrap` command is used to bootstrap the initial ACL token. nomad acl bootstrap [options] ``` -The `acl bootstrap` command requires no arguments. +The `acl bootstrap` command can be used in two ways: +- If you provide no arguments it will return a system generated bootstrap token. +- If you would like to provide an operator generated token it is possible to provide the token +using a file `acl bootstrap [path]`. The Token can be read from stdin by setting the path to "-". +Please make sure you secure this token in an apropriate manner as it could be written to your terminal history. ## General Options @@ -28,7 +32,7 @@ The `acl bootstrap` command requires no arguments. ## Examples -Bootstrap the initial token: +Bootstrap the initial token without arguments: ```shell-session $ nomad acl bootstrap @@ -42,3 +46,40 @@ Create Time = 2017-09-11 17:38:10.999089612 +0000 UTC Create Index = 7 Modify Index = 7 ``` + +Bootstrap the initial token with provided token: + +root.token file +```shell-session +2b778dd9-f5f1-6f29-b4b4-9a5fa948757a +``` + +```shell-session +$ nomad acl bootstrap root.token +Accessor ID = 5b7fd453-d3f7-6814-81dc-fcfe6daedea5 +Secret ID = 2b778dd9-f5f1-6f29-b4b4-9a5fa948757a +Name = Bootstrap Token +Type = management +Global = true +Policies = n/a +Create Time = 2017-09-11 17:38:10.999089612 +0000 UTC +Create Index = 7 +Modify Index = 7 +``` + +Bootstrap the initial token with provided token using stdin + +```shell-session +$ nomad acl bootstrap - +2b778dd9-f5f1-6f29-b4b4-9a5fa948757a +EOF +Accessor ID = 5b7fd453-d3f7-6814-81dc-fcfe6daedea5 +Secret ID = 2b778dd9-f5f1-6f29-b4b4-9a5fa948757a +Name = Bootstrap Token +Type = management +Global = true +Policies = n/a +Create Time = 2017-09-11 17:38:10.999089612 +0000 UTC +Create Index = 7 +Modify Index = 7 +```