Skip to content

Commit

Permalink
Allow Operator Generated bootstrap token (#14437)
Browse files Browse the repository at this point in the history
Add support to provide an initial token via the bootstrap HTTP API, similar to hashicorp/nomad#12520
  • Loading branch information
apollo13 authored and Chris S. Kim committed Jan 5, 2023
1 parent 0a00c8f commit 9545eec
Show file tree
Hide file tree
Showing 11 changed files with 264 additions and 13 deletions.
3 changes: 3 additions & 0 deletions .changelog/14437.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
acl: Added option to allow for an operator-generated bootstrap token to be passed to the `acl bootstrap` command.
```
13 changes: 12 additions & 1 deletion agent/acl_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/api"
"github.com/hashicorp/consul/lib"
)

Expand Down Expand Up @@ -34,9 +35,19 @@ func (s *HTTPHandlers) ACLBootstrap(resp http.ResponseWriter, req *http.Request)
return nil, aclDisabled
}

args := structs.DCSpecificRequest{
args := structs.ACLInitialTokenBootstrapRequest{
Datacenter: s.agent.config.Datacenter,
}

// Handle optional request body
if req.ContentLength > 0 {
var bootstrapSecretRequest api.BootstrapRequest
if err := lib.DecodeJSON(req.Body, &bootstrapSecretRequest); err != nil {
return nil, HTTPError{StatusCode: http.StatusBadRequest, Reason: fmt.Sprintf("Request decoding failed: %v", err)}
}
args.BootstrapSecret = bootstrapSecretRequest.BootstrapSecret
}

var out structs.ACLToken
err := s.agent.RPC(req.Context(), "ACL.BootstrapTokens", &args, &out)
if err != nil {
Expand Down
58 changes: 58 additions & 0 deletions agent/acl_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,64 @@ func TestACL_Bootstrap(t *testing.T) {
}
}

func TestACL_BootstrapWithToken(t *testing.T) {
if testing.Short() {
t.Skip("too slow for testing.Short")
}

t.Parallel()
a := NewTestAgent(t, `
primary_datacenter = "dc1"
acl {
enabled = true
default_policy = "deny"
}
`)
defer a.Shutdown()

tests := []struct {
name string
method string
code int
token bool
}{
{"bootstrap", "PUT", http.StatusOK, true},
{"not again", "PUT", http.StatusForbidden, false},
}
testrpc.WaitForLeader(t, a.RPC, "dc1")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var bootstrapSecret struct {
BootstrapSecret string
}
bootstrapSecret.BootstrapSecret = "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a"
resp := httptest.NewRecorder()
req, _ := http.NewRequest(tt.method, "/v1/acl/bootstrap", jsonBody(bootstrapSecret))
out, err := a.srv.ACLBootstrap(resp, req)
if tt.token && err != nil {
t.Fatalf("err: %v", err)
}
if tt.token {
wrap, ok := out.(*aclBootstrapResponse)
if !ok {
t.Fatalf("bad: %T", out)
}
if wrap.ID != bootstrapSecret.BootstrapSecret {
t.Fatalf("bad: %v", wrap)
}
if wrap.ID != wrap.SecretID {
t.Fatalf("bad: %v", wrap)
}
} else {
if out != nil {
t.Fatalf("bad: %T", out)
}
}
})
}
}

func TestACL_HTTP(t *testing.T) {
if testing.Short() {
t.Skip("too slow for testing.Short")
Expand Down
23 changes: 19 additions & 4 deletions agent/consul/acl_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ func (a *ACL) aclPreCheck() error {

// BootstrapTokens is used to perform a one-time ACL bootstrap operation on
// a cluster to get the first management token.
func (a *ACL) BootstrapTokens(args *structs.DCSpecificRequest, reply *structs.ACLToken) error {
func (a *ACL) BootstrapTokens(args *structs.ACLInitialTokenBootstrapRequest, reply *structs.ACLToken) error {
if err := a.aclPreCheck(); err != nil {
return err
}
Expand Down Expand Up @@ -207,9 +207,24 @@ func (a *ACL) BootstrapTokens(args *structs.DCSpecificRequest, reply *structs.AC
if err != nil {
return err
}
secret, err := lib.GenerateUUID(a.srv.checkTokenUUID)
if err != nil {
return err
secret := args.BootstrapSecret
if secret == "" {
secret, err = lib.GenerateUUID(a.srv.checkTokenUUID)
if err != nil {
return err
}
} else {
_, err = uuid.ParseUUID(secret)
if err != nil {
return err
}
ok, err := a.srv.checkTokenUUID(secret)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("Provided token cannot be used because a token with that secret already exists.")
}
}

req := structs.ACLTokenBootstrapRequest{
Expand Down
49 changes: 48 additions & 1 deletion agent/consul/acl_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func TestACLEndpoint_BootstrapTokens(t *testing.T) {
waitForLeaderEstablishment(t, srv)

// Expect an error initially since ACL bootstrap is not initialized.
arg := structs.DCSpecificRequest{
arg := structs.ACLInitialTokenBootstrapRequest{
Datacenter: "dc1",
}
var out structs.ACLToken
Expand Down Expand Up @@ -72,6 +72,53 @@ func TestACLEndpoint_BootstrapTokens(t *testing.T) {
require.Equal(t, out.CreateIndex, out.ModifyIndex)
}

func TestACLEndpoint_ProvidedBootstrapTokens(t *testing.T) {
if testing.Short() {
t.Skip("too slow for testing.Short")
}

t.Parallel()
_, srv, codec := testACLServerWithConfig(t, func(c *Config) {
// remove this as we are bootstrapping
c.ACLInitialManagementToken = ""
}, false)
waitForLeaderEstablishment(t, srv)

// Expect an error initially since ACL bootstrap is not initialized.
arg := structs.ACLInitialTokenBootstrapRequest{
Datacenter: "dc1",
BootstrapSecret: "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a",
}
var out structs.ACLToken
require.NoError(t, msgpackrpc.CallWithCodec(codec, "ACL.BootstrapTokens", &arg, &out))
require.Equal(t, out.SecretID, arg.BootstrapSecret)
require.Equal(t, 36, len(out.AccessorID))
require.True(t, strings.HasPrefix(out.Description, "Bootstrap Token"))
require.True(t, out.CreateIndex > 0)
require.Equal(t, out.CreateIndex, out.ModifyIndex)
}

func TestACLEndpoint_ProvidedBootstrapTokensInvalid(t *testing.T) {
if testing.Short() {
t.Skip("too slow for testing.Short")
}

t.Parallel()
_, srv, codec := testACLServerWithConfig(t, func(c *Config) {
// remove this as we are bootstrapping
c.ACLInitialManagementToken = ""
}, false)
waitForLeaderEstablishment(t, srv)

// Expect an error initially since ACL bootstrap is not initialized.
arg := structs.ACLInitialTokenBootstrapRequest{
Datacenter: "dc1",
BootstrapSecret: "abc",
}
var out structs.ACLToken
require.EqualError(t, msgpackrpc.CallWithCodec(codec, "ACL.BootstrapTokens", &arg, &out), "uuid string is wrong length")
}

func TestACLEndpoint_ReplicationStatus(t *testing.T) {
if testing.Short() {
t.Skip("too slow for testing.Short")
Expand Down
12 changes: 11 additions & 1 deletion agent/structs/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -1347,10 +1347,20 @@ type ACLTokenBatchDeleteRequest struct {
TokenIDs []string // Tokens to delete
}

type ACLInitialTokenBootstrapRequest struct {
BootstrapSecret string
Datacenter string
QueryOptions
}

func (r *ACLInitialTokenBootstrapRequest) RequestDatacenter() string {
return r.Datacenter
}

// ACLTokenBootstrapRequest is used only at the Raft layer
// for ACL bootstrapping
//
// The RPC layer will use a generic DCSpecificRequest to indicate
// The RPC layer will use ACLInitialTokenBootstrapRequest to indicate
// that bootstrapping must be performed but the actual token
// and the resetIndex will be generated by that RPC endpoint
type ACLTokenBootstrapRequest struct {
Expand Down
15 changes: 15 additions & 0 deletions api/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -498,10 +498,25 @@ func (c *Client) ACL() *ACL {
return &ACL{c}
}

// BootstrapRequest is used for when operators provide an ACL Bootstrap Token
type BootstrapRequest struct {
BootstrapSecret string
}

// Bootstrap is used to perform a one-time ACL bootstrap operation on a cluster
// to get the first management token.
func (a *ACL) Bootstrap() (*ACLToken, *WriteMeta, error) {
return a.BootstrapWithToken("")
}

// BootstrapWithToken is used to get the initial bootstrap token or pass in the one that was provided in the API
func (a *ACL) BootstrapWithToken(btoken string) (*ACLToken, *WriteMeta, error) {
r := a.c.newRequest("PUT", "/v1/acl/bootstrap")
if btoken != "" {
r.obj = &BootstrapRequest{
BootstrapSecret: btoken,
}
}
rtt, resp, err := a.c.doRequest(r)
if err != nil {
return nil, nil, err
Expand Down
26 changes: 25 additions & 1 deletion command/acl/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package bootstrap
import (
"flag"
"fmt"
"os"
"strings"

"github.com/hashicorp/consul/api"
"github.com/hashicorp/consul/command/acl/token"
"github.com/hashicorp/consul/command/flags"
"github.com/hashicorp/consul/command/helpers"
"github.com/mitchellh/cli"
)

Expand Down Expand Up @@ -43,13 +46,34 @@ func (c *cmd) Run(args []string) int {
return 1
}

args = c.flags.Args()
if l := len(args); l < 0 || l > 1 {
c.UI.Error("This command takes up to one argument")
return 1
}

var terminalToken string
var err error

if len(args) == 1 {
terminalToken, err = helpers.LoadDataSourceNoRaw(args[0], os.Stdin)
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.TrimSpace(terminalToken)

client, err := c.http.APIClient()
if err != nil {
c.UI.Error(fmt.Sprintf("Error connecting to Consul agent: %s", err))
return 1
}

t, _, err := client.ACL().Bootstrap()
var t *api.ACLToken
t, _, err = client.ACL().BootstrapWithToken(boottoken)
if err != nil {
c.UI.Error(fmt.Sprintf("Failed ACL bootstrapping: %v", err))
return 1
Expand Down
48 changes: 48 additions & 0 deletions command/acl/bootstrap/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bootstrap

import (
"encoding/json"
"os"
"strings"
"testing"

Expand Down Expand Up @@ -87,3 +88,50 @@ func TestBootstrapCommand_JSON(t *testing.T) {
err := json.Unmarshal([]byte(output), &jsonOutput)
require.NoError(t, err, "token unmarshalling error")
}

func TestBootstrapCommand_Initial(t *testing.T) {
if testing.Short() {
t.Skip("too slow for testing.Short")
}

t.Parallel()

a := agent.NewTestAgent(t, `
primary_datacenter = "dc1"
acl {
enabled = true
}`)

defer a.Shutdown()
testrpc.WaitForLeader(t, a.RPC, "dc1")

ui := cli.NewMockUi()
cmd := New(ui)

// Create temp file
f, err := os.CreateTemp("", "consul-token.token")
assert.Nil(t, err)
defer os.Remove(f.Name())

// Write the token to the file
err = os.WriteFile(f.Name(), []byte("2b778dd9-f5f1-6f29-b4b4-9a5fa948757a"), 0700)
assert.Nil(t, err)

args := []string{
"-http-addr=" + a.HTTPAddr(),
"-format=json",
f.Name(),
}

code := cmd.Run(args)
assert.Equal(t, code, 0)
assert.Empty(t, ui.ErrorWriter.String())
output := ui.OutputWriter.String()
assert.Contains(t, output, "Bootstrap Token")
assert.Contains(t, output, structs.ACLPolicyGlobalManagementID)
assert.Contains(t, output, "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a")

var jsonOutput json.RawMessage
err = json.Unmarshal([]byte(output), &jsonOutput)
require.NoError(t, err, "token unmarshalling error")
}
21 changes: 19 additions & 2 deletions website/content/api-docs/acl/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ the [ACL tutorial](https://learn.hashicorp.com/tutorials/consul/access-control-s
This endpoint does a special one-time bootstrap of the ACL system, making the first
management token if the [`acl.tokens.initial_management`](/docs/agent/config/config-files#acl_tokens_initial_management)
configuration entry is not specified in the Consul server configuration and if the
cluster has not been bootstrapped previously. This is available in Consul 0.9.1 and later,
and requires all Consul servers to be upgraded in order to operate.
cluster has not been bootstrapped previously. An operator created token can be provided in the body of the request to
bootstrap the cluster if required. The provided token should be presented in a UUID format.

This provides a mechanism to bootstrap ACLs without having any secrets present in Consul's
configuration files.
Expand Down Expand Up @@ -73,6 +73,23 @@ applications should ignore the `ID` field as it may be removed in a future major
}
```

### Sample Request with provided token


```json
{
"BootstrapSecret": "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a"
}
```

```shell-session
$ curl \
--request PUT \
--data @root-token.json \
http://127.0.0.1:8500/v1/acl/bootstrap
```


You can detect if something has interfered with the ACL bootstrapping process by
checking the response code. A 200 response means that the bootstrap was a success, and
a 403 means that the cluster has already been bootstrapped, at which point you should
Expand Down
Loading

0 comments on commit 9545eec

Please sign in to comment.