Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/27741.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
acl: Support uploading client ACL tokens
```
23 changes: 22 additions & 1 deletion api/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ func (a *ACLTokens) List(q *QueryOptions) ([]*ACLTokenListStub, *QueryMeta, erro
return resp, qm, nil
}

// Create is used to create a token
// Create is used to create a token with server-generated AccessorID and
// SecretID. Use Upload to create a token with pre-specified IDs.
func (a *ACLTokens) Create(token *ACLToken, q *WriteOptions) (*ACLToken, *WriteMeta, error) {
if token.AccessorID != "" {
return nil, nil, errors.New("cannot specify Accessor ID")
Expand All @@ -139,6 +140,26 @@ func (a *ACLTokens) Create(token *ACLToken, q *WriteOptions) (*ACLToken, *WriteM
return &resp, wm, nil
}

// Upload is used to create a client token with pre-specified AccessorID and
// SecretID. Management tokens cannot be uploaded and must be created with Create.
func (a *ACLTokens) Upload(token *ACLToken, q *WriteOptions) (*ACLToken, *WriteMeta, error) {
if token.AccessorID == "" {
return nil, nil, errors.New("missing accessor ID")
}
if token.SecretID == "" {
return nil, nil, errors.New("missing secret ID")
}
if token.Type == "management" {
return nil, nil, errors.New("cannot upload management tokens")
}
var resp ACLToken
wm, err := a.client.put("/v1/acl/token/"+token.AccessorID, token, &resp, q)
if err != nil {
return nil, nil, err
}
return &resp, wm, nil
}

// Update is used to update an existing token
func (a *ACLTokens) Update(token *ACLToken, q *WriteOptions) (*ACLToken, *WriteMeta, error) {
if token.AccessorID == "" {
Expand Down
76 changes: 76 additions & 0 deletions api/acl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,82 @@ func TestACLTokens_CreateUpdate(t *testing.T) {
must.Eq(t, role.Name, out3.Roles[0].Name)
}

func TestACLTokens_Upload(t *testing.T) {
testutil.Parallel(t)

client, s, _ := makeACLClient(t, nil, nil)
defer s.Stop()
tokens := client.ACLTokens()

aclPolicy := &ACLPolicy{
Name: "upload-test-policy",
Rules: `namespace "default" { policy = "read" }`,
}
_, err := client.ACLPolicies().Upsert(aclPolicy, nil)
must.NoError(t, err)

testCases := []struct {
desc string
token *ACLToken
expectedErr bool
errorMsg string
}{
{
desc: "missing AccessorID",
token: &ACLToken{
SecretID: "f5b20e34-9g6c-53d2-b8e4-7f1g2c3d5b6e",
},
expectedErr: true,
errorMsg: "missing accessor ID",
},
{
desc: "missing SecretID",
token: &ACLToken{
AccessorID: "b306571d-a3fa-42d2-ac5b-bbe49d8c3c7f",
},
expectedErr: true,
errorMsg: "missing secret ID",
},
{
desc: "management token",
token: &ACLToken{
AccessorID: "d172e5b4-7e3c-4a9e-bf01-8a5f2c9b1d0c",
SecretID: "e4a19d23-8f5b-42c1-a7d3-6e0f1b2c4a5d",
Name: "my management token",
Type: "management",
},
expectedErr: true,
errorMsg: "cannot upload management tokens",
},
{
desc: "valid client token",
token: &ACLToken{
AccessorID: "b306571d-a3fa-42d2-ac5b-bbe49d8c3c7f",
SecretID: "c28ba0f6-ab8d-4e4d-b9f0-7f9f5d1c2e3a",
Name: "my uploaded token",
Type: "client",
Policies: []string{aclPolicy.Name},
},
},
}

for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
tok, wm, err := tokens.Upload(tc.token, nil)
if tc.expectedErr {
must.ErrorContains(t, err, tc.errorMsg)
} else {
must.NoError(t, err)
assertWriteMeta(t, wm)
must.NotNil(t, tok)

must.Eq(t, tc.token.AccessorID, tok.AccessorID)
must.Eq(t, tc.token.SecretID, tok.SecretID)
}
})
}
}

func TestACLTokens_Info(t *testing.T) {
testutil.Parallel(t)

Expand Down
75 changes: 65 additions & 10 deletions command/acl_token_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package command

import (
"fmt"
"io"
"os"
"strings"
"time"

Expand All @@ -23,10 +25,16 @@ type ACLTokenCreateCommand struct {

func (c *ACLTokenCreateCommand) Help() string {
helpText := `
Usage: nomad acl token create [options]
Usage: nomad acl token create [options] [<path>]

Create is used to issue new ACL tokens. Requires a management token.

By default, Nomad generates the AccessorID and SecretID automatically. To
upload (restore) a client token with pre-specified IDs — for example, when
recovering tokens from a backup — provide -accessor and supply the SecretID
via a file at <path>, or pass "-" to read it from stdin. Only client tokens
may be uploaded; management tokens must always be created fresh.

General Options:

` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + `
Expand Down Expand Up @@ -57,6 +65,11 @@ Create Options:
a time duration such as "5m" and "1h". By default, tokens will be created
without a TTL and therefore never expire.

-accessor=""
Pre-specified AccessorID (UUID) for the token. When provided, the SecretID
must be supplied via <path> (a file or "-" for stdin). The token is uploaded
rather than generated. Only valid for client tokens.

-json
Output the ACL token information in JSON format.

Expand All @@ -76,6 +89,7 @@ func (c *ACLTokenCreateCommand) AutocompleteFlags() complete.Flags {
"role-id": complete.PredictAnything,
"role-name": complete.PredictAnything,
"ttl": complete.PredictAnything,
"accessor": complete.PredictAnything,
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
})
Expand All @@ -92,7 +106,7 @@ func (c *ACLTokenCreateCommand) Synopsis() string {
func (c *ACLTokenCreateCommand) Name() string { return "acl token create" }

func (c *ACLTokenCreateCommand) Run(args []string) int {
var name, tokenType, ttl, tmpl string
var name, tokenType, ttl, tmpl, accessorID string
var global, json bool
var policies []string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
Expand All @@ -103,6 +117,7 @@ func (c *ACLTokenCreateCommand) Run(args []string) int {
flags.StringVar(&ttl, "ttl", "", "")
flags.BoolVar(&json, "json", false, "")
flags.StringVar(&tmpl, "t", "", "")
flags.StringVar(&accessorID, "accessor", "", "")
flags.Var((funcVar)(func(s string) error {
policies = append(policies, s)
return nil
Expand All @@ -119,13 +134,42 @@ func (c *ACLTokenCreateCommand) Run(args []string) int {
return 1
}

// Check that we got no arguments
args = flags.Args()
if l := len(args); l != 0 {
c.Ui.Error(uiMessageNoArguments)
if l := len(args); l > 1 {
c.Ui.Error("This command takes up to one argument")
c.Ui.Error(commandErrorText(c))
return 1
}

// If -accessor is set, the caller must also supply the SecretID via a file
// or stdin (positional argument).
if accessorID != "" && len(args) == 0 {
c.Ui.Error("-accessor requires a SecretID supplied as a file path or \"-\" for stdin")
c.Ui.Error(commandErrorText(c))
return 1
}
if accessorID == "" && len(args) == 1 {
c.Ui.Error("A SecretID file path was provided but -accessor was not set")
c.Ui.Error(commandErrorText(c))
return 1
}

var secretID string
if len(args) == 1 {
var raw []byte
var err error
switch args[0] {
case "-":
raw, err = io.ReadAll(os.Stdin)
default:
raw, err = os.ReadFile(args[0])
}
if err != nil {
c.Ui.Error(fmt.Sprintf("Error reading SecretID: %v", err))
return 1
}
secretID = strings.TrimSuffix(string(raw), "\n")
}

// Set up the token.
tk := &api.ACLToken{
Expand Down Expand Up @@ -162,11 +206,22 @@ func (c *ACLTokenCreateCommand) Run(args []string) int {
}
}

// Create the bootstrap token
token, _, err := client.ACLTokens().Create(tk, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error creating token: %s", err))
return 1
var token *api.ACLToken

if accessorID != "" {
tk.AccessorID = accessorID
tk.SecretID = secretID
token, _, err = client.ACLTokens().Upload(tk, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error uploading token: %s", err))
return 1
}
} else {
token, _, err = client.ACLTokens().Create(tk, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error creating token: %s", err))
return 1
}
}

if json || len(tmpl) > 0 {
Expand Down
108 changes: 108 additions & 0 deletions command/acl_token_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package command

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

"github.com/hashicorp/cli"
Expand Down Expand Up @@ -94,6 +95,113 @@ func TestACLTokenCreateCommand(t *testing.T) {
}
}

func TestACLTokenCreateCommand_UploadFromFile(t *testing.T) {
ci.Parallel(t)

config := func(c *agent.Config) {
c.ACL.Enabled = true
}

srv, _, url := testServer(t, true, config)
defer srv.Shutdown()

token := srv.RootToken
must.NotNil(t, token)

ui := cli.NewMockUi()
cmd := &ACLTokenCreateCommand{Meta: Meta{Ui: ui, flagAddress: url}}

accessorID := "b306571d-a3fa-42d2-ac5b-bbe49d8c3c7f"
secretID := "c28ba0f6-ab8d-4e4d-b9f0-7f9f5d1c2e3a"

// Write the SecretID to a temp file
secretFile := t.TempDir() + "/secret.txt"
must.NoError(t, os.WriteFile(secretFile, []byte(secretID), 0600))

testCases := []struct {
desc string
args []string
expectedErr bool
errorMsg string
}{
{
desc: "providing -accessor without a SecretID file should fail",
args: []string{"-address=" + url, "-token=" + token.SecretID, "-type=client", "-policy=foo", "-accessor=" + accessorID},
expectedErr: true,
errorMsg: "-accessor requires a SecretID",
},
{
desc: "providing a SecretID file without -accessor should fail",
args: []string{"-address=" + url, "-token=" + token.SecretID, "-type=client", "-policy=foo", secretFile},
expectedErr: true,
errorMsg: "-accessor was not set",
},
{
desc: "successfully upload a client token with pre-specified IDs",
args: []string{"-address=" + url, "-token=" + token.SecretID, "-type=client", "-policy=foo", "-accessor=" + accessorID, secretFile},
expectedErr: false,
},
}

for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
code := cmd.Run(tc.args)
if tc.expectedErr {
must.One(t, code)
must.StrContains(t, ui.ErrorWriter.String(), tc.errorMsg)
} else {
must.Zero(t, code)
}
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
})
}
}

func TestACLTokenCreateCommand_UploadFromStdin(t *testing.T) {
config := func(c *agent.Config) {
c.ACL.Enabled = true
}

srv, _, url := testServer(t, true, config)
defer srv.Shutdown()

token := srv.RootToken
must.NotNil(t, token)

ui := cli.NewMockUi()
cmd := &ACLTokenCreateCommand{Meta: Meta{Ui: ui, flagAddress: url}}

accessorID := "fe7d9be4-4419-4c3a-b942-7e616c7f6f1f"
secretID := "2a1c0e84-0db7-45c7-bec0-5b9f3a0e0b49"

fakeStdin, err := os.CreateTemp("", "nomad-acl-token-secret")
must.NoError(t, err)
defer os.Remove(fakeStdin.Name())
defer fakeStdin.Close()

_, err = fakeStdin.WriteString(secretID + "\n")
must.NoError(t, err)
_, err = fakeStdin.Seek(0, 0)
must.NoError(t, err)

oldStdin := os.Stdin
os.Stdin = fakeStdin
defer func() { os.Stdin = oldStdin }()

code := cmd.Run([]string{
"-address=" + url,
"-token=" + token.SecretID,
"-type=client",
"-policy=foo",
"-accessor=" + accessorID,
"-",
})
must.Zero(t, code)
must.StrContains(t, ui.OutputWriter.String(), accessorID)
must.StrContains(t, ui.OutputWriter.String(), secretID)
}

func Test_generateACLTokenRoleLinks(t *testing.T) {
ci.Parallel(t)

Expand Down
Loading
Loading