diff --git a/.changelog/27741.txt b/.changelog/27741.txt new file mode 100644 index 00000000000..98041a2310b --- /dev/null +++ b/.changelog/27741.txt @@ -0,0 +1,3 @@ +```release-note:improvement +acl: Support uploading client ACL tokens +``` diff --git a/api/acl.go b/api/acl.go index 3305706a402..2a219c735e7 100644 --- a/api/acl.go +++ b/api/acl.go @@ -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") @@ -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 == "" { diff --git a/api/acl_test.go b/api/acl_test.go index 9f040d8e9a6..d59a95ecd81 100644 --- a/api/acl_test.go +++ b/api/acl_test.go @@ -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) diff --git a/command/acl_token_create.go b/command/acl_token_create.go index 7e3b1e0ee31..2c8c8990407 100644 --- a/command/acl_token_create.go +++ b/command/acl_token_create.go @@ -5,6 +5,8 @@ package command import ( "fmt" + "io" + "os" "strings" "time" @@ -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] [] 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 , 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) + ` @@ -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 (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. @@ -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, }) @@ -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) @@ -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 @@ -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{ @@ -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 { diff --git a/command/acl_token_create_test.go b/command/acl_token_create_test.go index 0b6b52ab3f9..7d97db2e0a6 100644 --- a/command/acl_token_create_test.go +++ b/command/acl_token_create_test.go @@ -5,6 +5,7 @@ package command import ( "encoding/json" + "os" "testing" "github.com/hashicorp/cli" @@ -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) diff --git a/command/agent/acl_endpoint_test.go b/command/agent/acl_endpoint_test.go index 79b2c2d2916..1dae0a58da1 100644 --- a/command/agent/acl_endpoint_test.go +++ b/command/agent/acl_endpoint_test.go @@ -582,6 +582,48 @@ func TestHTTP_ACLTokenCreateExpirationTTL(t *testing.T) { }) } +func TestHTTP_ACLTokenUpdate(t *testing.T) { + ci.Parallel(t) + httpACLTest(t, nil, func(s *TestAgent) { + token := &structs.ACLToken{ + AccessorID: uuid.Generate(), + SecretID: uuid.Generate(), + Name: "test token", + Type: "client", + Policies: []string{"foo"}, + ExpirationTTL: 10 * time.Hour, + } + + buf := encodeReq(token) + req, err := http.NewRequest(http.MethodPut, "/v1/acl/token/"+token.AccessorID, buf) + if err != nil { + t.Fatalf("err: %v", err) + } + respW := httptest.NewRecorder() + setToken(req, s.RootToken) + + // Make the request + obj, err := s.Server.ACLTokenSpecificRequest(respW, req) + assert.Nil(t, err) + assert.NotNil(t, obj) + outTK := obj.(*structs.ACLToken) + + // Check for the index + if respW.Result().Header.Get("X-Nomad-Index") == "" { + t.Fatalf("missing index") + } + + // Check token was created + state := s.Agent.server.State() + out, err := state.ACLTokenByAccessorID(nil, outTK.AccessorID) + assert.Nil(t, err) + assert.NotNil(t, out) + assert.Equal(t, outTK, out) + must.Eq(t, token.AccessorID, out.AccessorID) + must.Eq(t, token.SecretID, out.SecretID) + }) +} + func TestHTTP_ACLTokenDelete(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 78747e57434..1ed69966472 100644 --- a/nomad/acl_endpoint.go +++ b/nomad/acl_endpoint.go @@ -640,7 +640,12 @@ func (a *ACL) upsertTokens( return structs.NewErrRPCCodedf(http.StatusInternalServerError, "token lookup failed: %v", err) } if out == nil { - return structs.NewErrRPCCodedf(http.StatusBadRequest, "cannot find token %s", token.AccessorID) + // Check if the token is meant to uploaded + if !helper.IsUUID(token.AccessorID) || !helper.IsUUID(token.SecretID) || (token.Type == structs.ACLManagementToken) { + return structs.NewErrRPCCodedf(http.StatusBadRequest, "cannot find token %s", token.AccessorID) + } + + // existingToken remains nil, so this is treated as a creation. } existingToken = out } diff --git a/nomad/acl_endpoint_test.go b/nomad/acl_endpoint_test.go index 7494891c172..bf0697e7c9e 100644 --- a/nomad/acl_endpoint_test.go +++ b/nomad/acl_endpoint_test.go @@ -1959,6 +1959,57 @@ func TestACLEndpoint_UpsertTokens(t *testing.T) { ID: aclRole1.ID, Name: aclRole1.Name}}, tokenResp1.Tokens[0].Roles) }, }, + { + name: "upload client token with pre-specified ids", + testFn: func(testServer *Server, aclToken *structs.ACLToken) { + uploadToken := mock.ACLToken() + uploadToken.CreateIndex = 0 + uploadToken.ModifyIndex = 0 + + req := &structs.ACLTokenUpsertRequest{ + Tokens: []*structs.ACLToken{uploadToken}, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + AuthToken: aclToken.SecretID, + }, + } + var resp structs.ACLTokenUpsertResponse + require.NoError(t, msgpackrpc.CallWithCodec(codec, structs.ACLUpsertTokensRPCMethod, req, &resp)) + require.Len(t, resp.Tokens, 1) + + got := resp.Tokens[0] + require.Equal(t, uploadToken.AccessorID, got.AccessorID) + require.Equal(t, uploadToken.SecretID, got.SecretID) + + out, err := testServer.fsm.State().ACLTokenByAccessorID(nil, uploadToken.AccessorID) + require.NoError(t, err) + require.NotNil(t, out) + require.Equal(t, uploadToken.AccessorID, out.AccessorID) + require.Equal(t, uploadToken.SecretID, out.SecretID) + }, + }, + { + name: "cannot upload management token", + testFn: func(testServer *Server, aclToken *structs.ACLToken) { + mgmtToken := &structs.ACLToken{ + AccessorID: uuid.Generate(), + SecretID: uuid.Generate(), + Name: "my-mgmt-token-" + uuid.Generate(), + Type: structs.ACLManagementToken, + } + req := &structs.ACLTokenUpsertRequest{ + Tokens: []*structs.ACLToken{mgmtToken}, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + AuthToken: aclToken.SecretID, + }, + } + var resp structs.ACLTokenUpsertResponse + err := msgpackrpc.CallWithCodec(codec, structs.ACLUpsertTokensRPCMethod, req, &resp) + require.ErrorContains(t, err, "cannot find token") + require.Empty(t, resp.Tokens) + }, + }, } for _, tc := range testCases { diff --git a/nomad/structs/acl.go b/nomad/structs/acl.go index 1b7ef9c5f7d..223a53bbb47 100644 --- a/nomad/structs/acl.go +++ b/nomad/structs/acl.go @@ -727,17 +727,26 @@ type ACLTokenRoleLink struct { // set if it is empty, so copies should be taken if needed before calling this // function. func (a *ACLToken) Canonicalize() { - - // If the accessor ID is empty, it means this is creation of a new token, - // therefore we need to generate base information. + // If the accessor ID is empty, this is a new token being created: generate + // both IDs and initialize the creation timestamp. if a.AccessorID == "" { - a.AccessorID = uuid.Generate() a.SecretID = uuid.Generate() a.CreateTime = time.Now().UTC() // If the user has not set the expiration time, but has provided a TTL, we - // calculate and populate the former filed. + // calculate and populate the former field. + if a.ExpirationTime == nil && a.ExpirationTTL != 0 { + a.ExpirationTime = pointer.Of(a.CreateTime.Add(a.ExpirationTTL)) + } + return + } + + // If both IDs are already set but no creation time was provided, the token + // is being uploaded and the createTime should be set. + if a.CreateTime.IsZero() { + a.CreateTime = time.Now().UTC() + if a.ExpirationTime == nil && a.ExpirationTTL != 0 { a.ExpirationTime = pointer.Of(a.CreateTime.Add(a.ExpirationTTL)) } diff --git a/nomad/structs/acl_test.go b/nomad/structs/acl_test.go index a5b09fcb0c8..ec47938319f 100644 --- a/nomad/structs/acl_test.go +++ b/nomad/structs/acl_test.go @@ -80,6 +80,46 @@ func TestACLToken_Canonicalize(t *testing.T) { require.NotEmpty(t, mockToken.ExpirationTime) }, }, + { + name: "token upload with both ids but no create time", + testFn: func() { + accessorID := uuid.Generate() + secretID := uuid.Generate() + mockToken := &ACLToken{ + AccessorID: accessorID, + SecretID: secretID, + Name: "uploaded token " + uuid.Generate(), + Type: "client", + Policies: []string{"foo"}, + ExpirationTTL: 10 * time.Hour, + } + + mockToken.Canonicalize() + require.Equal(t, accessorID, mockToken.AccessorID) + require.Equal(t, secretID, mockToken.SecretID) + + require.NotEmpty(t, mockToken.CreateTime) + require.NotNil(t, mockToken.ExpirationTime) + require.Equal(t, 10*time.Hour, mockToken.ExpirationTime.Sub(mockToken.CreateTime)) + }, + }, + { + name: "token with create time set", + testFn: func() { + originalCreateTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + mockToken := &ACLToken{ + AccessorID: uuid.Generate(), + SecretID: uuid.Generate(), + Name: "uploaded token " + uuid.Generate(), + Type: "client", + Policies: []string{"foo"}, + CreateTime: originalCreateTime, + } + + mockToken.Canonicalize() + require.Equal(t, originalCreateTime, mockToken.CreateTime) + }, + }, } for _, tc := range testCases {