diff --git a/agent/consul/acl_endpoint.go b/agent/consul/acl_endpoint.go index c0f8725cca..2ee6770d44 100644 --- a/agent/consul/acl_endpoint.go +++ b/agent/consul/acl_endpoint.go @@ -2111,6 +2111,16 @@ func (a *ACL) AuthMethodSet(args *structs.ACLAuthMethodSetRequest, reply *struct return fmt.Errorf("Invalid Auth Method: Type should be one of: %v", authmethod.Types()) } + if method.MaxTokenTTL != 0 { + if method.MaxTokenTTL > a.srv.config.ACLTokenMaxExpirationTTL { + return fmt.Errorf("MaxTokenTTL %s cannot be more than %s", + method.MaxTokenTTL, a.srv.config.ACLTokenMaxExpirationTTL) + } else if method.MaxTokenTTL < a.srv.config.ACLTokenMinExpirationTTL { + return fmt.Errorf("MaxTokenTTL %s cannot be less than %s", + method.MaxTokenTTL, a.srv.config.ACLTokenMinExpirationTTL) + } + } + // Instantiate a validator but do not cache it yet. This will validate the // configuration. if _, err := authmethod.NewValidator(a.srv.logger, method); err != nil { @@ -2323,6 +2333,7 @@ func (a *ACL) Login(args *structs.ACLLoginRequest, reply *structs.ACLToken) erro AuthMethod: auth.AuthMethod, ServiceIdentities: serviceIdentities, Roles: roleLinks, + ExpirationTTL: method.MaxTokenTTL, EnterpriseMeta: *targetMeta, }, WriteRequest: args.WriteRequest, diff --git a/agent/consul/acl_endpoint_test.go b/agent/consul/acl_endpoint_test.go index d1dedecd14..7c27237a8e 100644 --- a/agent/consul/acl_endpoint_test.go +++ b/agent/consul/acl_endpoint_test.go @@ -3468,6 +3468,89 @@ func TestACLEndpoint_AuthMethodSet(t *testing.T) { } }) } + + t.Run("Create with MaxTokenTTL", func(t *testing.T) { + reqMethod := newAuthMethod("test") + reqMethod.MaxTokenTTL = 5 * time.Minute + + req := structs.ACLAuthMethodSetRequest{ + Datacenter: "dc1", + AuthMethod: reqMethod, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + resp := structs.ACLAuthMethod{} + + err := acl.AuthMethodSet(&req, &resp) + require.NoError(t, err) + + // Get the method directly to validate that it exists + methodResp, err := retrieveTestAuthMethod(codec, "root", "dc1", resp.Name) + require.NoError(t, err) + method := methodResp.AuthMethod + + require.Equal(t, method.Name, "test") + require.Equal(t, method.Description, "test") + require.Equal(t, method.Type, "testing") + require.Equal(t, method.MaxTokenTTL, 5*time.Minute) + }) + + t.Run("Update - change MaxTokenTTL", func(t *testing.T) { + reqMethod := newAuthMethod("test") + reqMethod.DisplayName = "updated display name 2" + reqMethod.Description = "test modified 2" + reqMethod.MaxTokenTTL = 8 * time.Minute + + req := structs.ACLAuthMethodSetRequest{ + Datacenter: "dc1", + AuthMethod: reqMethod, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + resp := structs.ACLAuthMethod{} + + err := acl.AuthMethodSet(&req, &resp) + require.NoError(t, err) + + // Get the method directly to validate that it exists + methodResp, err := retrieveTestAuthMethod(codec, "root", "dc1", resp.Name) + require.NoError(t, err) + method := methodResp.AuthMethod + + require.Equal(t, method.Name, "test") + require.Equal(t, method.DisplayName, "updated display name 2") + require.Equal(t, method.Description, "test modified 2") + require.Equal(t, method.Type, "testing") + require.Equal(t, method.MaxTokenTTL, 8*time.Minute) + }) + + t.Run("Create with MaxTokenTTL too small", func(t *testing.T) { + reqMethod := newAuthMethod("test") + reqMethod.MaxTokenTTL = 1 * time.Millisecond + + req := structs.ACLAuthMethodSetRequest{ + Datacenter: "dc1", + AuthMethod: reqMethod, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + resp := structs.ACLAuthMethod{} + + err := acl.AuthMethodSet(&req, &resp) + testutil.RequireErrorContains(t, err, "MaxTokenTTL 1ms cannot be less than") + }) + + t.Run("Create with MaxTokenTTL too big", func(t *testing.T) { + reqMethod := newAuthMethod("test") + reqMethod.MaxTokenTTL = 25 * time.Hour + + req := structs.ACLAuthMethodSetRequest{ + Datacenter: "dc1", + AuthMethod: reqMethod, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + resp := structs.ACLAuthMethod{} + + err := acl.AuthMethodSet(&req, &resp) + testutil.RequireErrorContains(t, err, "MaxTokenTTL 25h0m0s cannot be more than") + }) } func TestACLEndpoint_AuthMethodDelete(t *testing.T) { @@ -4942,6 +5025,81 @@ func TestACLEndpoint_Login(t *testing.T) { }) } +func TestACLEndpoint_Login_with_MaxTokenTTL(t *testing.T) { + t.Parallel() + + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLsEnabled = true + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + codec := rpcClient(t, s1) + defer codec.Close() + + testrpc.WaitForLeader(t, s1.RPC, "dc1") + + acl := ACL{srv: s1} + + testSessionID := testauth.StartSession() + defer testauth.ResetSession(testSessionID) + + testauth.InstallSessionToken( + testSessionID, + "fake-web", // no rules + "default", "web", "abc123", + ) + + method, err := upsertTestCustomizedAuthMethod(codec, "root", "dc1", func(method *structs.ACLAuthMethod) { + method.MaxTokenTTL = 5 * time.Minute + method.Config = map[string]interface{}{ + "SessionID": testSessionID, + } + }) + require.NoError(t, err) + + _, err = upsertTestBindingRule( + codec, "root", "dc1", method.Name, + "", + structs.BindingRuleBindTypeService, + "web", + ) + require.NoError(t, err) + + // Create a token. + req := structs.ACLLoginRequest{ + Auth: &structs.ACLLoginParams{ + AuthMethod: method.Name, + BearerToken: "fake-web", + Meta: map[string]string{"pod": "pod1"}, + }, + Datacenter: "dc1", + } + + resp := structs.ACLToken{} + require.NoError(t, acl.Login(&req, &resp)) + + got := &resp + got.CreateIndex = 0 + got.ModifyIndex = 0 + got.AccessorID = "" + got.SecretID = "" + got.Hash = nil + + expect := &structs.ACLToken{ + AuthMethod: method.Name, + Description: `token created via login: {"pod":"pod1"}`, + Local: true, + CreateTime: got.CreateTime, + ExpirationTime: timePointer(got.CreateTime.Add(method.MaxTokenTTL)), + ServiceIdentities: []*structs.ACLServiceIdentity{ + {ServiceName: "web"}, + }, + } + require.Equal(t, got, expect) +} + func TestACLEndpoint_Login_k8s(t *testing.T) { t.Parallel() diff --git a/agent/structs/acl.go b/agent/structs/acl.go index 4676fb28f3..35918315e8 100644 --- a/agent/structs/acl.go +++ b/agent/structs/acl.go @@ -2,6 +2,7 @@ package structs import ( "encoding/binary" + "encoding/json" "errors" "fmt" "hash" @@ -1048,6 +1049,9 @@ type ACLAuthMethod struct { // Description is just an optional bunch of explanatory text. Description string `json:",omitempty"` + // MaxTokenTTL this is the maximum life of a token created by this method. + MaxTokenTTL time.Duration `json:",omitempty"` + // Configuration is arbitrary configuration for the auth method. This // should only contain primitive values and containers (such as lists and // maps). @@ -1060,6 +1064,47 @@ type ACLAuthMethod struct { RaftIndex `hash:"ignore"` } +func (m *ACLAuthMethod) MarshalJSON() ([]byte, error) { + type Alias ACLAuthMethod + exported := &struct { + MaxTokenTTL string `json:",omitempty"` + *Alias + }{ + MaxTokenTTL: m.MaxTokenTTL.String(), + Alias: (*Alias)(m), + } + if m.MaxTokenTTL == 0 { + exported.MaxTokenTTL = "" + } + + return json.Marshal(exported) +} + +func (m *ACLAuthMethod) UnmarshalJSON(data []byte) (err error) { + type Alias ACLAuthMethod + aux := &struct { + MaxTokenTTL interface{} + *Alias + }{ + Alias: (*Alias)(m), + } + if err = lib.UnmarshalJSON(data, &aux); err != nil { + return err + } + if aux.MaxTokenTTL != nil { + switch v := aux.MaxTokenTTL.(type) { + case string: + if m.MaxTokenTTL, err = time.ParseDuration(v); err != nil { + return err + } + case float64: + m.MaxTokenTTL = time.Duration(v) + } + } + + return nil +} + type ACLReplicationType string const ( diff --git a/api/acl.go b/api/acl.go index 1974302784..6971d9a89b 100644 --- a/api/acl.go +++ b/api/acl.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "fmt" "io" "io/ioutil" @@ -182,8 +183,9 @@ type ACLBindingRule struct { type ACLAuthMethod struct { Name string Type string - DisplayName string `json:",omitempty"` - Description string `json:",omitempty"` + DisplayName string `json:",omitempty"` + Description string `json:",omitempty"` + MaxTokenTTL time.Duration `json:",omitempty"` // Configuration is arbitrary configuration for the auth method. This // should only contain primitive values and containers (such as lists and @@ -198,6 +200,43 @@ type ACLAuthMethod struct { Namespace string `json:",omitempty"` } +func (m *ACLAuthMethod) MarshalJSON() ([]byte, error) { + type Alias ACLAuthMethod + exported := &struct { + MaxTokenTTL string `json:",omitempty"` + *Alias + }{ + MaxTokenTTL: m.MaxTokenTTL.String(), + Alias: (*Alias)(m), + } + if m.MaxTokenTTL == 0 { + exported.MaxTokenTTL = "" + } + + return json.Marshal(exported) +} + +func (m *ACLAuthMethod) UnmarshalJSON(data []byte) error { + type Alias ACLAuthMethod + aux := &struct { + MaxTokenTTL string + *Alias + }{ + Alias: (*Alias)(m), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + var err error + if aux.MaxTokenTTL != "" { + if m.MaxTokenTTL, err = time.ParseDuration(aux.MaxTokenTTL); err != nil { + return err + } + } + + return nil +} + type ACLAuthMethodListEntry struct { Name string Type string diff --git a/command/acl/authmethod/create/authmethod_create.go b/command/acl/authmethod/create/authmethod_create.go index 507667cfec..32ec9a7e66 100644 --- a/command/acl/authmethod/create/authmethod_create.go +++ b/command/acl/authmethod/create/authmethod_create.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "strings" + "time" "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/command/acl/authmethod" @@ -30,11 +31,12 @@ type cmd struct { name string displayName string description string + maxTokenTTL time.Duration + config string k8sHost string k8sCACert string k8sServiceAccountJWT string - config string showMeta bool format string @@ -77,6 +79,12 @@ func (c *cmd) init() { "", "A description of the auth method.", ) + c.flags.DurationVar( + &c.maxTokenTTL, + "max-token-ttl", + 0, + "Duration of time all tokens created by this auth method should be valid for", + ) c.flags.StringVar( &c.k8sHost, @@ -150,6 +158,9 @@ func (c *cmd) Run(args []string) int { DisplayName: c.displayName, Description: c.description, } + if c.maxTokenTTL > 0 { + newAuthMethod.MaxTokenTTL = c.maxTokenTTL + } if c.config != "" { if c.k8sHost != "" || c.k8sCACert != "" || c.k8sServiceAccountJWT != "" { diff --git a/command/acl/authmethod/create/authmethod_create_test.go b/command/acl/authmethod/create/authmethod_create_test.go index d48af64b92..b4a0240092 100644 --- a/command/acl/authmethod/create/authmethod_create_test.go +++ b/command/acl/authmethod/create/authmethod_create_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/hashicorp/consul/agent" "github.com/hashicorp/consul/agent/connect" @@ -121,6 +122,35 @@ func TestAuthMethodCreateCommand(t *testing.T) { } require.Equal(t, expect, got) }) + + t.Run("create testing with max token ttl", func(t *testing.T) { + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-token=root", + "-type=testing", + "-name=test", + "-description=desc", + "-display-name=display", + "-max-token-ttl=5m", + } + + ui := cli.NewMockUi() + cmd := New(ui) + + code := cmd.Run(args) + require.Equal(t, code, 0, "err: "+ui.ErrorWriter.String()) + require.Empty(t, ui.ErrorWriter.String()) + + got := getTestMethod(t, client, "test") + expect := &api.ACLAuthMethod{ + Name: "test", + Type: "testing", + DisplayName: "display", + Description: "desc", + MaxTokenTTL: 5 * time.Minute, + } + require.Equal(t, expect, got) + }) } func TestAuthMethodCreateCommand_JSON(t *testing.T) { @@ -190,6 +220,53 @@ func TestAuthMethodCreateCommand_JSON(t *testing.T) { } require.Equal(t, expect, got) }) + + t.Run("create testing with max token ttl", func(t *testing.T) { + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-token=root", + "-type=testing", + "-name=test", + "-description=desc", + "-display-name=display", + "-max-token-ttl=5m", + "-format=json", + } + + ui := cli.NewMockUi() + cmd := New(ui) + + code := cmd.Run(args) + out := ui.OutputWriter.String() + + require.Equal(t, code, 0) + require.Empty(t, ui.ErrorWriter.String()) + require.Contains(t, out, "test") + + got := getTestMethod(t, client, "test") + expect := &api.ACLAuthMethod{ + Name: "test", + Type: "testing", + DisplayName: "display", + Description: "desc", + MaxTokenTTL: 5 * time.Minute, + } + require.Equal(t, expect, got) + + var raw map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(out), &raw)) + delete(raw, "CreateIndex") + delete(raw, "ModifyIndex") + + require.Equal(t, map[string]interface{}{ + "Name": "test", + "Type": "testing", + "DisplayName": "display", + "Description": "desc", + "MaxTokenTTL": "5m0s", + "Config": nil, + }, raw) + }) } func TestAuthMethodCreateCommand_k8s(t *testing.T) { diff --git a/command/acl/authmethod/update/authmethod_update.go b/command/acl/authmethod/update/authmethod_update.go index 4fe25cb995..1d6a3fca6c 100644 --- a/command/acl/authmethod/update/authmethod_update.go +++ b/command/acl/authmethod/update/authmethod_update.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "strings" + "time" "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/command/acl/authmethod" @@ -28,9 +29,11 @@ type cmd struct { name string - displayName string - description string - config string + displayName string + description string + maxTokenTTL time.Duration + config string + k8sHost string k8sCACert string k8sServiceAccountJWT string @@ -74,6 +77,13 @@ func (c *cmd) init() { "A description of the auth method.", ) + c.flags.DurationVar( + &c.maxTokenTTL, + "max-token-ttl", + 0, + "Duration of time all tokens created by this auth method should be valid for", + ) + c.flags.StringVar( &c.config, "config", @@ -169,6 +179,10 @@ func (c *cmd) Run(args []string) int { DisplayName: c.displayName, Description: c.description, } + if c.maxTokenTTL > 0 { + method.MaxTokenTTL = c.maxTokenTTL + } + if c.config != "" { if c.k8sHost != "" || c.k8sCACert != "" || c.k8sServiceAccountJWT != "" { c.UI.Error(fmt.Sprintf("Cannot use command line arguments with '-config' flag")) @@ -184,6 +198,7 @@ func (c *cmd) Run(args []string) int { return 1 } } + if currentAuthMethod.Type == "kubernetes" { if c.k8sHost == "" { c.UI.Error(fmt.Sprintf("Missing required '-kubernetes-host' flag")) @@ -211,6 +226,9 @@ func (c *cmd) Run(args []string) int { if c.displayName != "" { method.DisplayName = c.displayName } + if c.maxTokenTTL > 0 { + method.MaxTokenTTL = c.maxTokenTTL + } if c.config != "" { if c.k8sHost != "" || c.k8sCACert != "" || c.k8sServiceAccountJWT != "" { c.UI.Error(fmt.Sprintf("Cannot use command line arguments with '-config' flag")) diff --git a/website/pages/api-docs/acl/auth-methods.mdx b/website/pages/api-docs/acl/auth-methods.mdx index 3c104e91bf..9e10b944be 100644 --- a/website/pages/api-docs/acl/auth-methods.mdx +++ b/website/pages/api-docs/acl/auth-methods.mdx @@ -51,6 +51,14 @@ The table below shows this endpoint's support for - `DisplayName` `(string: "")` - An optional name to use instead of the `Name` field when displaying information about this auth method. Added in Consul 1.8.0. +- `MaxTokenTTL` `(duration: 0s)` - This specifies the maximum life of any token + created by this auth method. When set it will initialize the + [`ExpirationTime`](/api/acl/tokens.html#expirationtime) field on all tokens + to a value of `Token.CreateTime + AuthMethod.MaxTokenTTL`. This field is not + persisted beyond its initial use. Can be specified in the form of `"60s"` or + `"5m"` (i.e., 60 seconds or 5 minutes, respectively). This value must be no + smaller than 1 minute and no longer than 24 hours. Added in Consul 1.8.0. + - `Config` `(map[string]string: )` - The raw configuration to use for the chosen auth method. Contents will vary depending upon the type chosen. For more information on configuring specific auth method types, see the [auth @@ -191,6 +199,14 @@ The table below shows this endpoint's support for - `DisplayName` `(string: "")` - An optional name to use instead of the `Name` field when displaying information about this auth method. Added in Consul 1.8.0. +- `MaxTokenTTL` `(duration: 0s)` - This specifies the maximum life of any token + created by this auth method. When set it will initialize the + [`ExpirationTime`](/api/acl/tokens.html#expirationtime) field on all tokens + to a value of `Token.CreateTime + AuthMethod.MaxTokenTTL`. This field is not + persisted beyond its initial use. Can be specified in the form of `"60s"` or + `"5m"` (i.e., 60 seconds or 5 minutes, respectively). This value must be no + smaller than 1 minute and no longer than 24 hours. Added in Consul 1.8.0. + - `Config` `(map[string]string: )` - The raw configuration to use for the chosen auth method. Contents will vary depending upon the type chosen. For more information on configuring specific auth method types, see the [auth