secrets/consul: Add support for generating tokens with service and node identities (#15295)

Co-authored-by: Thomas L. Kula <kula@tproa.net>
This commit is contained in:
Robert 2022-05-09 20:07:35 -05:00 committed by GitHub
parent 0dc6728228
commit 738753b187
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 519 additions and 82 deletions

View File

@ -564,8 +564,7 @@ func TestBackend_role_lease(t *testing.T) {
})
}
func testAccStepConfig(
t *testing.T, config map[string]interface{}) logicaltest.TestStep {
func testAccStepConfig(t *testing.T, config map[string]interface{}) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.UpdateOperation,
Path: "config/access",
@ -573,8 +572,7 @@ func testAccStepConfig(
}
}
func testAccStepReadToken(
t *testing.T, name string, conf map[string]interface{}) logicaltest.TestStep {
func testAccStepReadToken(t *testing.T, name string, conf map[string]interface{}) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.ReadOperation,
Path: "creds/" + name,
@ -610,8 +608,7 @@ func testAccStepReadToken(
}
}
func testAccStepReadManagementToken(
t *testing.T, name string, conf map[string]interface{}) logicaltest.TestStep {
func testAccStepReadManagementToken(t *testing.T, name string, conf map[string]interface{}) logicaltest.TestStep {
return logicaltest.TestStep{
Operation: logical.ReadOperation,
Path: "creds/" + name,
@ -1082,6 +1079,239 @@ func testBackendEntPartition(t *testing.T) {
}
}
func TestBackendRenewRevokeRolesAndIdentities(t *testing.T) {
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
b, err := Factory(context.Background(), config)
if err != nil {
t.Fatal(err)
}
cleanup, consulConfig := consul.PrepareTestContainer(t, "", false, true)
defer cleanup()
connData := map[string]interface{}{
"address": consulConfig.Address(),
"token": consulConfig.Token,
}
req := &logical.Request{
Storage: config.StorageView,
Operation: logical.UpdateOperation,
Path: "config/access",
Data: connData,
}
resp, err := b.HandleRequest(context.Background(), req)
if err != nil {
t.Fatal(err)
}
cases := map[string]struct {
RoleName string
RoleData map[string]interface{}
}{
"just role": {
"r",
map[string]interface{}{
"consul_roles": []string{"role-test"},
"lease": "6h",
},
},
"role and policies": {
"rp",
map[string]interface{}{
"policies": []string{"test"},
"consul_roles": []string{"role-test"},
"lease": "6h",
},
},
"service identity": {
"si",
map[string]interface{}{
"service_identities": "service1",
"lease": "6h",
},
},
"service identity and policies": {
"sip",
map[string]interface{}{
"policies": []string{"test"},
"service_identities": "service1",
"lease": "6h",
},
},
"service identity and role": {
"sir",
map[string]interface{}{
"consul_roles": []string{"role-test"},
"service_identities": "service1",
"lease": "6h",
},
},
"service identity and role and policies": {
"sirp",
map[string]interface{}{
"policies": []string{"test"},
"consul_roles": []string{"role-test"},
"service_identities": "service1",
"lease": "6h",
},
},
"node identity": {
"ni",
map[string]interface{}{
"node_identities": []string{"node1:dc1"},
"lease": "6h",
},
},
"node identity and policies": {
"nip",
map[string]interface{}{
"policies": []string{"test"},
"node_identities": []string{"node1:dc1"},
"lease": "6h",
},
},
"node identity and role": {
"nir",
map[string]interface{}{
"consul_roles": []string{"role-test"},
"node_identities": []string{"node1:dc1"},
"lease": "6h",
},
},
"node identity and role and policies": {
"nirp",
map[string]interface{}{
"consul_roles": []string{"role-test"},
"service_identities": "service1",
"node_identities": []string{"node1:dc1"},
"lease": "6h",
},
},
"node identity and service identity": {
"nisi",
map[string]interface{}{
"service_identities": "service1",
"node_identities": []string{"node1:dc1"},
"lease": "6h",
},
},
"node identity and service identity and policies": {
"nisip",
map[string]interface{}{
"policies": []string{"test"},
"service_identities": "service1",
"node_identities": []string{"node1:dc1"},
"lease": "6h",
},
},
"node identity and service identity and role": {
"nisir",
map[string]interface{}{
"consul_roles": []string{"role-test"},
"service_identities": "service1",
"node_identities": []string{"node1:dc1"},
"lease": "6h",
},
},
"node identity and service identity and role and policies": {
"nisirp",
map[string]interface{}{
"policies": []string{"test"},
"consul_roles": []string{"role-test"},
"service_identities": "service1",
"node_identities": []string{"node1:dc1"},
"lease": "6h",
},
},
}
for description, tc := range cases {
t.Logf("Testing: %s", description)
req.Operation = logical.UpdateOperation
req.Path = fmt.Sprintf("roles/%s", tc.RoleName)
req.Data = tc.RoleData
resp, err = b.HandleRequest(context.Background(), req)
if err != nil {
t.Fatal(err)
}
req.Operation = logical.ReadOperation
req.Path = fmt.Sprintf("creds/%s", tc.RoleName)
resp, err = b.HandleRequest(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if resp == nil {
t.Fatal("resp nil")
}
if resp.IsError() {
t.Fatalf("resp is error: %v", resp.Error())
}
generatedSecret := resp.Secret
generatedSecret.TTL = 6 * time.Hour
var d struct {
Token string `mapstructure:"token"`
Accessor string `mapstructure:"accessor"`
}
if err := mapstructure.Decode(resp.Data, &d); err != nil {
t.Fatal(err)
}
t.Logf("Generated token: %s with accessor %s", d.Token, d.Accessor)
// Build a client and verify that the credentials work
consulapiConfig := consulapi.DefaultNonPooledConfig()
consulapiConfig.Address = connData["address"].(string)
consulapiConfig.Token = d.Token
client, err := consulapi.NewClient(consulapiConfig)
if err != nil {
t.Fatal(err)
}
t.Log("Verifying that the generated token works...")
_, err = client.Catalog(), nil
if err != nil {
t.Fatal(err)
}
req.Operation = logical.RenewOperation
req.Secret = generatedSecret
resp, err = b.HandleRequest(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if resp == nil {
t.Fatal("got nil response from renew")
}
req.Operation = logical.RevokeOperation
resp, err = b.HandleRequest(context.Background(), req)
if err != nil {
t.Fatal(err)
}
// Build a management client and verify that the token does not exist anymore
consulmgmtConfig := consulapi.DefaultNonPooledConfig()
consulmgmtConfig.Address = connData["address"].(string)
consulmgmtConfig.Token = connData["token"].(string)
mgmtclient, err := consulapi.NewClient(consulmgmtConfig)
q := &consulapi.QueryOptions{
Datacenter: "DC1",
}
t.Log("Verifying that the generated token does not exist...")
_, _, err = mgmtclient.ACL().TokenRead(d.Accessor, q)
if err == nil {
t.Fatal("err: expected error")
}
}
}
const testPolicy = `
key "" {
policy = "write"

View File

@ -89,6 +89,18 @@ created within. Defaults to 'default'. Available in Consul 1.7 and above.`,
Description: `Indicates which admin partition that the token
will be created within. Defaults to 'default'. Available in Consul 1.11 and above.`,
},
"service_identities": {
Type: framework.TypeStringSlice,
Description: `List of Service Identities to attach to the
token, separated by semicolons. Available in Consul 1.5 or above.`,
},
"node_identities": {
Type: framework.TypeStringSlice,
Description: `List of Node Identities to attach to the
token. Available in Consul 1.8.1 or above.`,
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
@ -149,6 +161,13 @@ func (b *backend) pathRolesRead(ctx context.Context, req *logical.Request, d *fr
if len(roleConfigData.ConsulRoles) > 0 {
resp.Data["consul_roles"] = roleConfigData.ConsulRoles
}
if len(roleConfigData.ServiceIdentities) > 0 {
resp.Data["service_identities"] = roleConfigData.ServiceIdentities
}
if len(roleConfigData.NodeIdentities) > 0 {
resp.Data["node_identities"] = roleConfigData.NodeIdentities
}
return resp, nil
}
@ -157,17 +176,19 @@ func (b *backend) pathRolesWrite(ctx context.Context, req *logical.Request, d *f
policy := d.Get("policy").(string)
policies := d.Get("policies").([]string)
roles := d.Get("consul_roles").([]string)
serviceIdentities := d.Get("service_identities").([]string)
nodeIdentities := d.Get("node_identities").([]string)
switch tokenType {
case "client":
if policy == "" && len(policies) == 0 && len(roles) == 0 {
if policy == "" && len(policies) == 0 && len(roles) == 0 &&
len(serviceIdentities) == 0 && len(nodeIdentities) == 0 {
return logical.ErrorResponse(
"Use either a policy document, a list of policies, or a list of roles, depending on your Consul version"), nil
"Use either a policy document, a list of policies or roles, or a set of service or node identities, depending on your Consul version"), nil
}
case "management":
default:
return logical.ErrorResponse(
"token_type must be \"client\" or \"management\""), nil
return logical.ErrorResponse("token_type must be \"client\" or \"management\""), nil
}
policyRaw, err := base64.StdEncoding.DecodeString(policy)
@ -201,6 +222,8 @@ func (b *backend) pathRolesWrite(ctx context.Context, req *logical.Request, d *f
Policy: string(policyRaw),
Policies: policies,
ConsulRoles: roles,
ServiceIdentities: serviceIdentities,
NodeIdentities: nodeIdentities,
TokenType: tokenType,
TTL: ttl,
MaxTTL: maxTTL,
@ -231,6 +254,8 @@ type roleConfig struct {
Policy string `json:"policy"`
Policies []string `json:"policies"`
ConsulRoles []string `json:"consul_roles"`
ServiceIdentities []string `json:"service_identities"`
NodeIdentities []string `json:"node_identities"`
TTL time.Duration `json:"lease"`
MaxTTL time.Duration `json:"max_ttl"`
TokenType string `json:"token_type"`

View File

@ -3,6 +3,7 @@ package consul
import (
"context"
"fmt"
"strings"
"time"
"github.com/hashicorp/consul/api"
@ -103,10 +104,15 @@ func (b *backend) pathTokenRead(ctx context.Context, req *logical.Request, d *fr
})
}
aclServiceIdentities := parseServiceIdentities(roleConfigData.ServiceIdentities)
aclNodeIdentities := parseNodeIdentities(roleConfigData.NodeIdentities)
token, _, err := c.ACL().TokenCreate(&api.ACLToken{
Description: tokenName,
Policies: policyLinks,
Roles: roleLinks,
ServiceIdentities: aclServiceIdentities,
NodeIdentities: aclNodeIdentities,
Local: roleConfigData.Local,
Namespace: roleConfigData.ConsulNamespace,
Partition: roleConfigData.Partition,
@ -132,3 +138,35 @@ func (b *backend) pathTokenRead(ctx context.Context, req *logical.Request, d *fr
return s, nil
}
func parseServiceIdentities(data []string) []*api.ACLServiceIdentity {
aclServiceIdentities := []*api.ACLServiceIdentity{}
for _, serviceIdentity := range data {
entry := &api.ACLServiceIdentity{}
components := strings.Split(serviceIdentity, ":")
entry.ServiceName = components[0]
if len(components) == 2 {
entry.Datacenters = strings.Split(components[1], ",")
}
aclServiceIdentities = append(aclServiceIdentities, entry)
}
return aclServiceIdentities
}
func parseNodeIdentities(data []string) []*api.ACLNodeIdentity {
aclNodeIdentities := []*api.ACLNodeIdentity{}
for _, nodeIdentity := range data {
entry := &api.ACLNodeIdentity{}
components := strings.Split(nodeIdentity, ":")
entry.NodeName = components[0]
if len(components) > 1 {
entry.Datacenter = components[1]
}
aclNodeIdentities = append(aclNodeIdentities, entry)
}
return aclNodeIdentities
}

View File

@ -0,0 +1,104 @@
package consul
import (
"reflect"
"testing"
"github.com/hashicorp/consul/api"
)
func Test_parseServiceIdentities(t *testing.T) {
tests := []struct {
name string
args []string
want []*api.ACLServiceIdentity
}{
{
name: "No datacenters",
args: []string{"myservice-1"},
want: []*api.ACLServiceIdentity{{ServiceName: "myservice-1", Datacenters: nil}},
},
{
name: "One datacenter",
args: []string{"myservice-1:dc1"},
want: []*api.ACLServiceIdentity{{ServiceName: "myservice-1", Datacenters: []string{"dc1"}}},
},
{
name: "Multiple datacenters",
args: []string{"myservice-1:dc1,dc2,dc3"},
want: []*api.ACLServiceIdentity{{ServiceName: "myservice-1", Datacenters: []string{"dc1", "dc2", "dc3"}}},
},
{
name: "Missing service name with datacenter",
args: []string{":dc1"},
want: []*api.ACLServiceIdentity{{ServiceName: "", Datacenters: []string{"dc1"}}},
},
{
name: "Missing service name and missing datacenter",
args: []string{""},
want: []*api.ACLServiceIdentity{{ServiceName: "", Datacenters: nil}},
},
{
name: "Multiple service identities",
args: []string{"myservice-1:dc1", "myservice-2:dc1", "myservice-3:dc1,dc2"},
want: []*api.ACLServiceIdentity{
{ServiceName: "myservice-1", Datacenters: []string{"dc1"}},
{ServiceName: "myservice-2", Datacenters: []string{"dc1"}},
{ServiceName: "myservice-3", Datacenters: []string{"dc1", "dc2"}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseServiceIdentities(tt.args); !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseServiceIdentities() = {%s:%v}, want {%s:%v}", got[0].ServiceName, got[0].Datacenters, tt.want[0].ServiceName, tt.want[0].Datacenters)
}
})
}
}
func Test_parseNodeIdentities(t *testing.T) {
tests := []struct {
name string
args []string
want []*api.ACLNodeIdentity
}{
{
name: "No datacenter",
args: []string{"server-1"},
want: []*api.ACLNodeIdentity{{NodeName: "server-1", Datacenter: ""}},
},
{
name: "One datacenter",
args: []string{"server-1:dc1"},
want: []*api.ACLNodeIdentity{{NodeName: "server-1", Datacenter: "dc1"}},
},
{
name: "Missing node name with datacenter",
args: []string{":dc1"},
want: []*api.ACLNodeIdentity{{NodeName: "", Datacenter: "dc1"}},
},
{
name: "Missing node name and missing datacenter",
args: []string{""},
want: []*api.ACLNodeIdentity{{NodeName: "", Datacenter: ""}},
},
{
name: "Multiple node identities",
args: []string{"server-1:dc1", "server-2:dc1", "server-3:dc1"},
want: []*api.ACLNodeIdentity{
{NodeName: "server-1", Datacenter: "dc1"},
{NodeName: "server-2", Datacenter: "dc1"},
{NodeName: "server-3", Datacenter: "dc1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseNodeIdentities(tt.args); !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseNodeIdentities() = {%s:%s}, want {%s:%s}", got[0].NodeName, got[0].Datacenter, tt.want[0].NodeName, tt.want[0].Datacenter)
}
})
}
}

3
changelog/15295.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
secrets/consul: Add support for Consul node-identities and service-identities
```

View File

@ -148,7 +148,7 @@ func PrepareTestContainer(t *testing.T, version string, isEnterprise bool, boots
currVersion, _ := goversion.NewVersion(version)
roleVersion, _ := goversion.NewVersion("1.5")
if currVersion.GreaterThanOrEqual(roleVersion) {
ACLList := []*consulapi.ACLLink{{Name: "test"}}
ACLList := []*consulapi.ACLTokenRoleLink{{Name: "test"}}
role := &consulapi.ACLRole{
Name: "role-test",

View File

@ -74,13 +74,6 @@ updated attributes.
| :----- | :-------------------- |
| `POST` | `/consul/roles/:name` |
### Parameters for Consul versions 1.7 and above
- `consul_namespace` `(string: "")` <EnterpriseAlert inline /> - Specifies the Consul namespace the token
will be generated within. The namespace must exist, and the policies or roles assigned to the
Vault role must also exist inside the given Consul namespace. If not provided, the "default"
namespace is used.
### Parameters for Consul versions 1.11 and above
- `partition` `(string: "")` <EnterpriseAlert inline /> - Specifies the Consul admin partition the token
@ -88,14 +81,6 @@ updated attributes.
Vault role must also exist inside the given partition. If not provided, the "default"
partition is used.
To create a client token within a particular Consul namespace:
```json
{
"consul_namespace": "ns1"
}
```
To create a client token within a particular Consul admin partition:
```json
@ -104,7 +89,34 @@ To create a client token within a particular Consul admin partition:
}
```
### Parameters for Consul versions 1.4 and above
### Parameters for Consul versions 1.8 and above
- `consul_namespace` `(string: "")` <EnterpriseAlert inline /> - Specifies the Consul namespace the token
will be generated within. The namespace must exist, and the policies or roles assigned to the
Vault role must also exist inside the given Consul namespace. If not provided, the "default"
namespace is used.
- `node_identities` `(list: <node identity or identities>)` - The list of node identities to
assign to the generated token. This may be a comma-separated list to attach multiple node identities
to a token.
To create a client token within a particular Consul namespace:
```json
{
"consul_namespace": "ns1"
}
```
To create a client token with node identities attached:
```json
{
"node_identities": "client-1:dc1,client-2:dc1"
}
```
### Parameters for Consul versions 1.5 and above
- `name` `(string: <required>)`  Specifies the name of an existing role against
which to create this Consul credential. This is part of the request URL.
@ -125,6 +137,10 @@ To create a client token within a particular Consul admin partition:
- `consul_roles` `(list: <role or roles>)` The list of Consul roles to assign to the
generated token. Either `policies` or `consul_roles` are required for Consul 1.5 and above.
- `service_identities` `(list: <service identity or identities>)` - The list of
service identities to assign to the generated token. This may be a semicolon-separated list to
attach multiple service identities to a token.
- `local` `(bool: false)` - Indicates that the token should not be replicated
globally and instead be local to the current datacenter. Only available in Consul
1.4 and greater.
@ -163,6 +179,14 @@ To create a client token with defined roles:
}
```
To create a client token with service identities attached:
```json
{
"service_identities": "myservice-1:dc1,dc2;myservice-2:dc1"
}
```
### Sample Request
```shell-session

View File

@ -38,7 +38,7 @@ management tool.
If you have already bootstrapped the ACL system of your Consul cluster, you
will need to give Vault a management token:
- In Consul versions below 1.4, acquire a [management token][consul-mgmt-token] from Consul, using the
In Consul versions below 1.4, acquire a [management token][consul-mgmt-token] from Consul, using the
`acl_master_token` from your Consul configuration file or another management
token:
@ -59,7 +59,7 @@ management tool.
}
```
- For Consul 1.4 and above, use the command line to generate a token with the appropriate policy:
For Consul 1.4 and above, use the command line to generate a token with the appropriate policy:
```shell-session
$ CONSUL_HTTP_TOKEN="<management-token>" consul acl token create -policy-name="global-management"
@ -82,7 +82,8 @@ management tool.
```
1. Configure a role that maps a name in Vault to a Consul ACL policy. Depending on your Consul version,
you will either provide a policy document and a token_type, or a set of policies.
you will either provide a policy document and a token_type, a list of policies or roles, or a set of
service or node identities.
When users generate credentials, they are generated against this role.
For Consul versions below 1.4, the policy must be base64-encoded. The policy language is
@ -103,14 +104,26 @@ management tool.
Success! Data written to: consul/roles/my-role
```
For Consul versions 1.5 and above, [generate a role in Consul](https://www.consul.io/api/acl/roles), and
proceed to link it to the role:
For Consul versions 1.5 and above, [generate a role in Consul](https://www.consul.io/api/acl/roles) and
proceed to link it to the role, or [attach a Consul service identity](https://www.consul.io/commands/acl/token/create#service-identity) to the role:
```shell-session
$ vault write consul/roles/my-role consul_roles="api-server"
Success! Data written to: consul/roles/my-role
```
```shell-session
$ vault write consul/roles/my-role service_identities="myservice:dc1,dc2"
Success! Data written to: consul/roles/my-role
```
For Consul versions 1.8 and above, [attach a Consul node identity](https://www.consul.io/commands/acl/token/create#node-identity) to the role.
```shell-session
$ vault write consul/roles/my-role node_identities="server-1:dc1"
Success! Data written to: consul/roles/my-role
```
-> **Token lease duration:** If you do not specify a value for `ttl` (or `lease` for Consul versions below 1.4) the
tokens created using Vault's Consul secrets engine are created with a Time To Live (TTL) of 30 days. You can change
the lease duration by passing `-ttl=<duration>` to the command above with "duration" being a string with a time