99e0a124cb
This PR is almost a complete rewrite of the ACL system within Consul. It brings the features more in line with other HashiCorp products. Obviously there is quite a bit left to do here but most of it is related docs, testing and finishing the last few commands in the CLI. I will update the PR description and check off the todos as I finish them over the next few days/week. Description At a high level this PR is mainly to split ACL tokens from Policies and to split the concepts of Authorization from Identities. A lot of this PR is mostly just to support CRUD operations on ACLTokens and ACLPolicies. These in and of themselves are not particularly interesting. The bigger conceptual changes are in how tokens get resolved, how backwards compatibility is handled and the separation of policy from identity which could lead the way to allowing for alternative identity providers. On the surface and with a new cluster the ACL system will look very similar to that of Nomads. Both have tokens and policies. Both have local tokens. The ACL management APIs for both are very similar. I even ripped off Nomad's ACL bootstrap resetting procedure. There are a few key differences though. Nomad requires token and policy replication where Consul only requires policy replication with token replication being opt-in. In Consul local tokens only work with token replication being enabled though. All policies in Nomad are globally applicable. In Consul all policies are stored and replicated globally but can be scoped to a subset of the datacenters. This allows for more granular access management. Unlike Nomad, Consul has legacy baggage in the form of the original ACL system. The ramifications of this are: A server running the new system must still support other clients using the legacy system. A client running the new system must be able to use the legacy RPCs when the servers in its datacenter are running the legacy system. The primary ACL DC's servers running in legacy mode needs to be a gate that keeps everything else in the entire multi-DC cluster running in legacy mode. So not only does this PR implement the new ACL system but has a legacy mode built in for when the cluster isn't ready for new ACLs. Also detecting that new ACLs can be used is automatic and requires no configuration on the part of administrators. This process is detailed more in the "Transitioning from Legacy to New ACL Mode" section below.
493 lines
14 KiB
Go
493 lines
14 KiB
Go
package agent
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/armon/go-metrics"
|
|
"github.com/hashicorp/consul/acl"
|
|
"github.com/hashicorp/consul/agent/config"
|
|
"github.com/hashicorp/consul/agent/consul"
|
|
"github.com/hashicorp/consul/agent/local"
|
|
"github.com/hashicorp/consul/agent/structs"
|
|
"github.com/hashicorp/consul/lib"
|
|
"github.com/hashicorp/consul/logger"
|
|
"github.com/hashicorp/consul/types"
|
|
"github.com/hashicorp/serf/serf"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type TestACLAgent struct {
|
|
// Name is an optional name of the agent.
|
|
Name string
|
|
|
|
HCL string
|
|
|
|
// Config is the agent configuration. If Config is nil then
|
|
// TestConfig() is used. If Config.DataDir is set then it is
|
|
// the callers responsibility to clean up the data directory.
|
|
// Otherwise, a temporary data directory is created and removed
|
|
// when Shutdown() is called.
|
|
Config *config.RuntimeConfig
|
|
|
|
// LogOutput is the sink for the logs. If nil, logs are written
|
|
// to os.Stderr.
|
|
LogOutput io.Writer
|
|
|
|
// LogWriter is used for streaming logs.
|
|
LogWriter *logger.LogWriter
|
|
|
|
// DataDir is the data directory which is used when Config.DataDir
|
|
// is not set. It is created automatically and removed when
|
|
// Shutdown() is called.
|
|
DataDir string
|
|
|
|
resolveTokenFn func(string) (acl.Authorizer, error)
|
|
|
|
*Agent
|
|
}
|
|
|
|
// NewTestACLAGent does just enough so that all the code within agent/acl.go can work
|
|
// Basically it needs a local state for some of the vet* functions, a logger and a delegate.
|
|
// The key is that we are the delegate so we can control the ResolveToken responses
|
|
func NewTestACLAgent(name string, hcl string, resolveFn func(string) (acl.Authorizer, error)) *TestACLAgent {
|
|
a := &TestACLAgent{Name: name, HCL: hcl, resolveTokenFn: resolveFn}
|
|
hclDataDir := `data_dir = "acl-agent"`
|
|
|
|
a.Config = TestConfig(
|
|
config.Source{Name: a.Name, Format: "hcl", Data: a.HCL},
|
|
config.Source{Name: a.Name + ".data_dir", Format: "hcl", Data: hclDataDir},
|
|
)
|
|
|
|
agent, err := New(a.Config)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("Error creating agent: %v", err))
|
|
}
|
|
a.Agent = agent
|
|
|
|
logOutput := a.LogOutput
|
|
if logOutput == nil {
|
|
logOutput = os.Stderr
|
|
}
|
|
agent.LogOutput = logOutput
|
|
agent.LogWriter = a.LogWriter
|
|
agent.logger = log.New(logOutput, a.Name+" - ", log.LstdFlags|log.Lmicroseconds)
|
|
agent.MemSink = metrics.NewInmemSink(1*time.Second, time.Minute)
|
|
|
|
a.Agent.delegate = a
|
|
a.Agent.State = local.NewState(LocalConfig(a.Config), a.Agent.logger, a.Agent.tokens)
|
|
a.Agent.State.TriggerSyncChanges = func() {}
|
|
return a
|
|
}
|
|
|
|
func (a *TestACLAgent) ACLsEnabled() bool {
|
|
// the TestACLAgent always has ACLs enabled
|
|
return true
|
|
}
|
|
|
|
func (a *TestACLAgent) UseLegacyACLs() bool {
|
|
return false
|
|
}
|
|
|
|
func (a *TestACLAgent) ResolveToken(secretID string) (acl.Authorizer, error) {
|
|
if a.resolveTokenFn == nil {
|
|
panic("This agent is useless without providing a token resolution function")
|
|
}
|
|
|
|
return a.resolveTokenFn(secretID)
|
|
}
|
|
|
|
// All of these are stubs to satisfy the interface
|
|
func (a *TestACLAgent) Encrypted() bool {
|
|
return false
|
|
}
|
|
func (a *TestACLAgent) GetLANCoordinate() (lib.CoordinateSet, error) {
|
|
return nil, fmt.Errorf("Unimplemented")
|
|
}
|
|
func (a *TestACLAgent) Leave() error {
|
|
return fmt.Errorf("Unimplemented")
|
|
}
|
|
func (a *TestACLAgent) LANMembers() []serf.Member {
|
|
return nil
|
|
}
|
|
func (a *TestACLAgent) LANMembersAllSegments() ([]serf.Member, error) {
|
|
return nil, fmt.Errorf("Unimplemented")
|
|
}
|
|
func (a *TestACLAgent) LANSegmentMembers(segment string) ([]serf.Member, error) {
|
|
return nil, fmt.Errorf("Unimplemented")
|
|
}
|
|
func (a *TestACLAgent) LocalMember() serf.Member {
|
|
return serf.Member{}
|
|
}
|
|
func (a *TestACLAgent) JoinLAN(addrs []string) (n int, err error) {
|
|
return 0, fmt.Errorf("Unimplemented")
|
|
}
|
|
func (a *TestACLAgent) RemoveFailedNode(node string) error {
|
|
return fmt.Errorf("Unimplemented")
|
|
}
|
|
|
|
func (a *TestACLAgent) RPC(method string, args interface{}, reply interface{}) error {
|
|
return fmt.Errorf("Unimplemented")
|
|
}
|
|
func (a *TestACLAgent) SnapshotRPC(args *structs.SnapshotRequest, in io.Reader, out io.Writer, replyFn structs.SnapshotReplyFn) error {
|
|
return fmt.Errorf("Unimplemented")
|
|
}
|
|
func (a *TestACLAgent) Shutdown() error {
|
|
return fmt.Errorf("Unimplemented")
|
|
}
|
|
func (a *TestACLAgent) Stats() map[string]map[string]string {
|
|
return nil
|
|
}
|
|
func (a *TestACLAgent) ReloadConfig(config *consul.Config) error {
|
|
return fmt.Errorf("Unimplemented")
|
|
}
|
|
|
|
func TestACL_Version8(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("version 8 disabled", func(t *testing.T) {
|
|
resolveFn := func(string) (acl.Authorizer, error) {
|
|
require.Fail(t, "should not have called delegate.ResolveToken")
|
|
return nil, fmt.Errorf("should not have called delegate.ResolveToken")
|
|
}
|
|
|
|
a := NewTestACLAgent(t.Name(), TestACLConfig()+`
|
|
acl_enforce_version_8 = false
|
|
`, resolveFn)
|
|
|
|
token, err := a.resolveToken("nope")
|
|
require.Nil(t, token)
|
|
require.Nil(t, err)
|
|
})
|
|
|
|
t.Run("version 8 enabled", func(t *testing.T) {
|
|
called := false
|
|
resolveFn := func(string) (acl.Authorizer, error) {
|
|
called = true
|
|
return nil, acl.ErrNotFound
|
|
}
|
|
a := NewTestACLAgent(t.Name(), TestACLConfig()+`
|
|
acl_enforce_version_8 = true
|
|
`, resolveFn)
|
|
|
|
_, err := a.resolveToken("nope")
|
|
require.Error(t, err)
|
|
require.True(t, called)
|
|
})
|
|
}
|
|
|
|
func TestACL_AgentMasterToken(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resolveFn := func(string) (acl.Authorizer, error) {
|
|
require.Fail(t, "should not have called delegate.ResolveToken")
|
|
return nil, fmt.Errorf("should not have called delegate.ResolveToken")
|
|
}
|
|
|
|
a := NewTestACLAgent(t.Name(), TestACLConfig(), resolveFn)
|
|
authz, err := a.resolveToken("towel")
|
|
require.NotNil(t, authz)
|
|
require.Nil(t, err)
|
|
|
|
require.True(t, authz.AgentRead(a.config.NodeName))
|
|
require.True(t, authz.AgentWrite(a.config.NodeName))
|
|
require.True(t, authz.NodeRead("foobarbaz"))
|
|
require.False(t, authz.NodeWrite("foobarbaz", nil))
|
|
}
|
|
|
|
func TestACL_RootAuthorizersDenied(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
resolveFn := func(string) (acl.Authorizer, error) {
|
|
require.Fail(t, "should not have called delegate.ResolveToken")
|
|
return nil, fmt.Errorf("should not have called delegate.ResolveToken")
|
|
}
|
|
|
|
a := NewTestACLAgent(t.Name(), TestACLConfig(), resolveFn)
|
|
authz, err := a.resolveToken("deny")
|
|
require.Nil(t, authz)
|
|
require.Error(t, err)
|
|
require.True(t, acl.IsErrRootDenied(err))
|
|
authz, err = a.resolveToken("allow")
|
|
require.Nil(t, authz)
|
|
require.Error(t, err)
|
|
require.True(t, acl.IsErrRootDenied(err))
|
|
authz, err = a.resolveToken("manage")
|
|
require.Nil(t, authz)
|
|
require.Error(t, err)
|
|
require.True(t, acl.IsErrRootDenied(err))
|
|
}
|
|
|
|
func authzFromPolicy(policy *acl.Policy) (acl.Authorizer, error) {
|
|
return acl.NewPolicyAuthorizer(acl.DenyAll(), []*acl.Policy{policy}, nil)
|
|
}
|
|
|
|
// catalogPolicy supplies some standard policies to help with testing the
|
|
// catalog-related vet and filter functions.
|
|
func catalogPolicy(token string) (acl.Authorizer, error) {
|
|
switch token {
|
|
|
|
case "node-ro":
|
|
return authzFromPolicy(&acl.Policy{
|
|
NodePrefixes: []*acl.NodePolicy{
|
|
&acl.NodePolicy{Name: "Node", Policy: "read"},
|
|
},
|
|
})
|
|
case "node-rw":
|
|
return authzFromPolicy(&acl.Policy{
|
|
NodePrefixes: []*acl.NodePolicy{
|
|
&acl.NodePolicy{Name: "Node", Policy: "write"},
|
|
},
|
|
})
|
|
case "service-ro":
|
|
return authzFromPolicy(&acl.Policy{
|
|
ServicePrefixes: []*acl.ServicePolicy{
|
|
&acl.ServicePolicy{Name: "service", Policy: "read"},
|
|
},
|
|
})
|
|
case "service-rw":
|
|
return authzFromPolicy(&acl.Policy{
|
|
ServicePrefixes: []*acl.ServicePolicy{
|
|
&acl.ServicePolicy{Name: "service", Policy: "write"},
|
|
},
|
|
})
|
|
case "other-rw":
|
|
return authzFromPolicy(&acl.Policy{
|
|
ServicePrefixes: []*acl.ServicePolicy{
|
|
&acl.ServicePolicy{Name: "other", Policy: "write"},
|
|
},
|
|
})
|
|
default:
|
|
return nil, fmt.Errorf("unknown token %q", token)
|
|
}
|
|
}
|
|
|
|
func TestACL_vetServiceRegister(t *testing.T) {
|
|
t.Parallel()
|
|
a := NewTestACLAgent(t.Name(), TestACLConfig(), catalogPolicy)
|
|
|
|
// Register a new service, with permission.
|
|
err := a.vetServiceRegister("service-rw", &structs.NodeService{
|
|
ID: "my-service",
|
|
Service: "service",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Register a new service without write privs.
|
|
err = a.vetServiceRegister("service-ro", &structs.NodeService{
|
|
ID: "my-service",
|
|
Service: "service",
|
|
})
|
|
require.True(t, acl.IsErrPermissionDenied(err))
|
|
|
|
// Try to register over a service without write privs to the existing
|
|
// service.
|
|
a.State.AddService(&structs.NodeService{
|
|
ID: "my-service",
|
|
Service: "other",
|
|
}, "")
|
|
err = a.vetServiceRegister("service-rw", &structs.NodeService{
|
|
ID: "my-service",
|
|
Service: "service",
|
|
})
|
|
require.True(t, acl.IsErrPermissionDenied(err))
|
|
}
|
|
|
|
func TestACL_vetServiceUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
a := NewTestACLAgent(t.Name(), TestACLConfig(), catalogPolicy)
|
|
|
|
// Update a service that doesn't exist.
|
|
err := a.vetServiceUpdate("service-rw", "my-service")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "Unknown service")
|
|
|
|
// Update with write privs.
|
|
a.State.AddService(&structs.NodeService{
|
|
ID: "my-service",
|
|
Service: "service",
|
|
}, "")
|
|
err = a.vetServiceUpdate("service-rw", "my-service")
|
|
require.NoError(t, err)
|
|
|
|
// Update without write privs.
|
|
err = a.vetServiceUpdate("service-ro", "my-service")
|
|
require.Error(t, err)
|
|
require.True(t, acl.IsErrPermissionDenied(err))
|
|
}
|
|
|
|
func TestACL_vetCheckRegister(t *testing.T) {
|
|
t.Parallel()
|
|
a := NewTestACLAgent(t.Name(), TestACLConfig(), catalogPolicy)
|
|
|
|
// Register a new service check with write privs.
|
|
err := a.vetCheckRegister("service-rw", &structs.HealthCheck{
|
|
CheckID: types.CheckID("my-check"),
|
|
ServiceID: "my-service",
|
|
ServiceName: "service",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Register a new service check without write privs.
|
|
err = a.vetCheckRegister("service-ro", &structs.HealthCheck{
|
|
CheckID: types.CheckID("my-check"),
|
|
ServiceID: "my-service",
|
|
ServiceName: "service",
|
|
})
|
|
require.Error(t, err)
|
|
require.True(t, acl.IsErrPermissionDenied(err))
|
|
|
|
// Register a new node check with write privs.
|
|
err = a.vetCheckRegister("node-rw", &structs.HealthCheck{
|
|
CheckID: types.CheckID("my-check"),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Register a new node check without write privs.
|
|
err = a.vetCheckRegister("node-ro", &structs.HealthCheck{
|
|
CheckID: types.CheckID("my-check"),
|
|
})
|
|
require.Error(t, err)
|
|
require.True(t, acl.IsErrPermissionDenied(err))
|
|
|
|
// Try to register over a service check without write privs to the
|
|
// existing service.
|
|
a.State.AddService(&structs.NodeService{
|
|
ID: "my-service",
|
|
Service: "service",
|
|
}, "")
|
|
a.State.AddCheck(&structs.HealthCheck{
|
|
CheckID: types.CheckID("my-check"),
|
|
ServiceID: "my-service",
|
|
ServiceName: "other",
|
|
}, "")
|
|
err = a.vetCheckRegister("service-rw", &structs.HealthCheck{
|
|
CheckID: types.CheckID("my-check"),
|
|
ServiceID: "my-service",
|
|
ServiceName: "service",
|
|
})
|
|
require.Error(t, err)
|
|
require.True(t, acl.IsErrPermissionDenied(err))
|
|
|
|
// Try to register over a node check without write privs to the node.
|
|
a.State.AddCheck(&structs.HealthCheck{
|
|
CheckID: types.CheckID("my-node-check"),
|
|
}, "")
|
|
err = a.vetCheckRegister("service-rw", &structs.HealthCheck{
|
|
CheckID: types.CheckID("my-node-check"),
|
|
ServiceID: "my-service",
|
|
ServiceName: "service",
|
|
})
|
|
require.Error(t, err)
|
|
require.True(t, acl.IsErrPermissionDenied(err))
|
|
}
|
|
|
|
func TestACL_vetCheckUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
a := NewTestACLAgent(t.Name(), TestACLConfig(), catalogPolicy)
|
|
|
|
// Update a check that doesn't exist.
|
|
err := a.vetCheckUpdate("node-rw", "my-check")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "Unknown check")
|
|
|
|
// Update service check with write privs.
|
|
a.State.AddService(&structs.NodeService{
|
|
ID: "my-service",
|
|
Service: "service",
|
|
}, "")
|
|
a.State.AddCheck(&structs.HealthCheck{
|
|
CheckID: types.CheckID("my-service-check"),
|
|
ServiceID: "my-service",
|
|
ServiceName: "service",
|
|
}, "")
|
|
err = a.vetCheckUpdate("service-rw", "my-service-check")
|
|
require.NoError(t, err)
|
|
|
|
// Update service check without write privs.
|
|
err = a.vetCheckUpdate("service-ro", "my-service-check")
|
|
require.Error(t, err)
|
|
require.True(t, acl.IsErrPermissionDenied(err))
|
|
|
|
// Update node check with write privs.
|
|
a.State.AddCheck(&structs.HealthCheck{
|
|
CheckID: types.CheckID("my-node-check"),
|
|
}, "")
|
|
err = a.vetCheckUpdate("node-rw", "my-node-check")
|
|
require.NoError(t, err)
|
|
|
|
// Update without write privs.
|
|
err = a.vetCheckUpdate("node-ro", "my-node-check")
|
|
require.Error(t, err)
|
|
require.True(t, acl.IsErrPermissionDenied(err))
|
|
}
|
|
|
|
func TestACL_filterMembers(t *testing.T) {
|
|
t.Parallel()
|
|
a := NewTestACLAgent(t.Name(), TestACLConfig(), catalogPolicy)
|
|
|
|
var members []serf.Member
|
|
require.NoError(t, a.filterMembers("node-ro", &members))
|
|
require.Len(t, members, 0)
|
|
|
|
members = []serf.Member{
|
|
serf.Member{Name: "Node 1"},
|
|
serf.Member{Name: "Nope"},
|
|
serf.Member{Name: "Node 2"},
|
|
}
|
|
require.NoError(t, a.filterMembers("node-ro", &members))
|
|
require.Len(t, members, 2)
|
|
require.Equal(t, members[0].Name, "Node 1")
|
|
require.Equal(t, members[1].Name, "Node 2")
|
|
}
|
|
|
|
func TestACL_filterServices(t *testing.T) {
|
|
t.Parallel()
|
|
a := NewTestACLAgent(t.Name(), TestACLConfig(), catalogPolicy)
|
|
|
|
services := make(map[string]*structs.NodeService)
|
|
require.NoError(t, a.filterServices("node-ro", &services))
|
|
|
|
services["my-service"] = &structs.NodeService{ID: "my-service", Service: "service"}
|
|
services["my-other"] = &structs.NodeService{ID: "my-other", Service: "other"}
|
|
require.NoError(t, a.filterServices("service-ro", &services))
|
|
require.Contains(t, services, "my-service")
|
|
require.NotContains(t, services, "my-other")
|
|
}
|
|
|
|
func TestACL_filterChecks(t *testing.T) {
|
|
t.Parallel()
|
|
a := NewTestACLAgent(t.Name(), TestACLConfig(), catalogPolicy)
|
|
|
|
checks := make(map[types.CheckID]*structs.HealthCheck)
|
|
require.NoError(t, a.filterChecks("node-ro", &checks))
|
|
|
|
checks["my-node"] = &structs.HealthCheck{}
|
|
checks["my-service"] = &structs.HealthCheck{ServiceName: "service"}
|
|
checks["my-other"] = &structs.HealthCheck{ServiceName: "other"}
|
|
require.NoError(t, a.filterChecks("service-ro", &checks))
|
|
fmt.Printf("filtered: %#v", checks)
|
|
_, ok := checks["my-node"]
|
|
require.False(t, ok)
|
|
_, ok = checks["my-service"]
|
|
require.True(t, ok)
|
|
_, ok = checks["my-other"]
|
|
require.False(t, ok)
|
|
|
|
checks["my-node"] = &structs.HealthCheck{}
|
|
checks["my-service"] = &structs.HealthCheck{ServiceName: "service"}
|
|
checks["my-other"] = &structs.HealthCheck{ServiceName: "other"}
|
|
require.NoError(t, a.filterChecks("node-ro", &checks))
|
|
_, ok = checks["my-node"]
|
|
require.True(t, ok)
|
|
_, ok = checks["my-service"]
|
|
require.False(t, ok)
|
|
_, ok = checks["my-other"]
|
|
require.False(t, ok)
|
|
}
|