diff --git a/agent/consul/intention_endpoint.go b/agent/consul/intention_endpoint.go index 2458a8ee9..7662ea852 100644 --- a/agent/consul/intention_endpoint.go +++ b/agent/consul/intention_endpoint.go @@ -7,6 +7,7 @@ import ( "github.com/armon/go-metrics" "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/connect" "github.com/hashicorp/consul/agent/consul/state" "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/go-memdb" @@ -252,3 +253,92 @@ func (s *Intention) Match( }, ) } + +// Test tests a source/destination and returns whether it would be allowed +// or denied based on the current ACL configuration. +func (s *Intention) Test( + args *structs.IntentionQueryRequest, + reply *structs.IntentionQueryTestResponse) error { + // Get the test args, and defensively guard against nil + query := args.Test + if query == nil { + return errors.New("Test must be specified on args") + } + + // Build the URI + var uri connect.CertURI + switch query.SourceType { + case structs.IntentionSourceConsul: + uri = &connect.SpiffeIDService{ + Namespace: query.SourceNS, + Service: query.SourceName, + } + + default: + return fmt.Errorf("unsupported SourceType: %q", query.SourceType) + } + + // Get the ACL token for the request for the checks below. + rule, err := s.srv.resolveToken(args.Token) + if err != nil { + return err + } + + // Perform the ACL check + if prefix, ok := query.GetACLPrefix(); ok { + if rule != nil && !rule.ServiceRead(prefix) { + s.srv.logger.Printf("[WARN] consul.intention: test on intention '%s' denied due to ACLs", prefix) + return acl.ErrPermissionDenied + } + } + + // Get the matches for this destination + state := s.srv.fsm.State() + _, matches, err := state.IntentionMatch(nil, &structs.IntentionQueryMatch{ + Type: structs.IntentionMatchDestination, + Entries: []structs.IntentionMatchEntry{ + structs.IntentionMatchEntry{ + Namespace: query.DestinationNS, + Name: query.DestinationName, + }, + }, + }) + if err != nil { + return err + } + if len(matches) != 1 { + // This should never happen since the documented behavior of the + // Match call is that it'll always return exactly the number of results + // as entries passed in. But we guard against misbehavior. + return errors.New("internal error loading matches") + } + + // Test the authorization for each match + for _, ixn := range matches[0] { + if auth, ok := uri.Authorize(ixn); ok { + reply.Allowed = auth + return nil + } + } + + // No match, we need to determine the default behavior. We do this by + // specifying the anonymous token token, which will get that behavior. + // The default behavior if ACLs are disabled is to allow connections + // to mimic the behavior of Consul itself: everything is allowed if + // ACLs are disabled. + // + // NOTE(mitchellh): This is the same behavior as the agent authorize + // endpoint. If this behavior is incorrect, we should also change it there + // which is much more important. + rule, err = s.srv.resolveToken("") + if err != nil { + return err + } + + reply.Allowed = true + if rule != nil { + reply.Allowed = rule.IntentionDefaultAllow() + } + + return nil +} diff --git a/agent/consul/intention_endpoint_test.go b/agent/consul/intention_endpoint_test.go index dfac4fc45..b1f51a714 100644 --- a/agent/consul/intention_endpoint_test.go +++ b/agent/consul/intention_endpoint_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/consul/testrpc" "github.com/hashicorp/net-rpc-msgpackrpc" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // Test basic creation @@ -1007,3 +1008,256 @@ service "bar" { assert.Equal(expected, actual) } } + +// Test the Test method defaults to allow with no ACL set. +func TestIntentionTest_defaultNoACL(t *testing.T) { + t.Parallel() + + require := require.New(t) + dir1, s1 := testServer(t) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + codec := rpcClient(t, s1) + defer codec.Close() + + testrpc.WaitForLeader(t, s1.RPC, "dc1") + + // Test + req := &structs.IntentionQueryRequest{ + Datacenter: "dc1", + Test: &structs.IntentionQueryTest{ + SourceNS: "foo", + SourceName: "bar", + DestinationNS: "foo", + DestinationName: "qux", + SourceType: structs.IntentionSourceConsul, + }, + } + var resp structs.IntentionQueryTestResponse + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Test", req, &resp)) + require.True(resp.Allowed) +} + +// Test the Test method defaults to deny with whitelist ACLs. +func TestIntentionTest_defaultACLDeny(t *testing.T) { + t.Parallel() + + require := require.New(t) + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + c.ACLDefaultPolicy = "deny" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + codec := rpcClient(t, s1) + defer codec.Close() + + testrpc.WaitForLeader(t, s1.RPC, "dc1") + + // Test + req := &structs.IntentionQueryRequest{ + Datacenter: "dc1", + Test: &structs.IntentionQueryTest{ + SourceNS: "foo", + SourceName: "bar", + DestinationNS: "foo", + DestinationName: "qux", + SourceType: structs.IntentionSourceConsul, + }, + } + req.Token = "root" + var resp structs.IntentionQueryTestResponse + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Test", req, &resp)) + require.False(resp.Allowed) +} + +// Test the Test method defaults to deny with blacklist ACLs. +func TestIntentionTest_defaultACLAllow(t *testing.T) { + t.Parallel() + + require := require.New(t) + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + c.ACLDefaultPolicy = "allow" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + codec := rpcClient(t, s1) + defer codec.Close() + + testrpc.WaitForLeader(t, s1.RPC, "dc1") + + // Test + req := &structs.IntentionQueryRequest{ + Datacenter: "dc1", + Test: &structs.IntentionQueryTest{ + SourceNS: "foo", + SourceName: "bar", + DestinationNS: "foo", + DestinationName: "qux", + SourceType: structs.IntentionSourceConsul, + }, + } + req.Token = "root" + var resp structs.IntentionQueryTestResponse + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Test", req, &resp)) + require.True(resp.Allowed) +} + +// Test the Test method requires service:read permission. +func TestIntentionTest_aclDeny(t *testing.T) { + t.Parallel() + + require := require.New(t) + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + c.ACLDefaultPolicy = "deny" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + codec := rpcClient(t, s1) + defer codec.Close() + + testrpc.WaitForLeader(t, s1.RPC, "dc1") + + // Create an ACL with service read permissions. This will grant permission. + var token string + { + var rules = ` +service "bar" { + policy = "read" +}` + + req := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: rules, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + require.Nil(msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) + } + + // Test + req := &structs.IntentionQueryRequest{ + Datacenter: "dc1", + Test: &structs.IntentionQueryTest{ + SourceNS: "foo", + SourceName: "qux", + DestinationNS: "foo", + DestinationName: "baz", + SourceType: structs.IntentionSourceConsul, + }, + } + req.Token = token + var resp structs.IntentionQueryTestResponse + err := msgpackrpc.CallWithCodec(codec, "Intention.Test", req, &resp) + require.True(acl.IsErrPermissionDenied(err)) +} + +// Test the Test method returns allow/deny properly. +func TestIntentionTest_match(t *testing.T) { + t.Parallel() + + require := require.New(t) + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + c.ACLDefaultPolicy = "deny" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + codec := rpcClient(t, s1) + defer codec.Close() + + testrpc.WaitForLeader(t, s1.RPC, "dc1") + + // Create an ACL with service read permissions. This will grant permission. + var token string + { + var rules = ` +service "bar" { + policy = "read" +}` + + req := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: rules, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + require.Nil(msgpackrpc.CallWithCodec(codec, "ACL.Apply", &req, &token)) + } + + // Create some intentions + { + insert := [][]string{ + {"foo", "*", "foo", "*"}, + {"foo", "*", "foo", "bar"}, + {"bar", "*", "foo", "bar"}, // duplicate destination different source + } + + for _, v := range insert { + ixn := structs.IntentionRequest{ + Datacenter: "dc1", + Op: structs.IntentionOpCreate, + Intention: &structs.Intention{ + SourceNS: v[0], + SourceName: v[1], + DestinationNS: v[2], + DestinationName: v[3], + Action: structs.IntentionActionAllow, + }, + } + ixn.WriteRequest.Token = "root" + + // Create + var reply string + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Apply", &ixn, &reply)) + } + } + + // Test + req := &structs.IntentionQueryRequest{ + Datacenter: "dc1", + Test: &structs.IntentionQueryTest{ + SourceNS: "foo", + SourceName: "qux", + DestinationNS: "foo", + DestinationName: "bar", + SourceType: structs.IntentionSourceConsul, + }, + } + req.Token = token + var resp structs.IntentionQueryTestResponse + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Test", req, &resp)) + require.True(resp.Allowed) + + // Test no match for sanity + { + req := &structs.IntentionQueryRequest{ + Datacenter: "dc1", + Test: &structs.IntentionQueryTest{ + SourceNS: "baz", + SourceName: "qux", + DestinationNS: "foo", + DestinationName: "bar", + SourceType: structs.IntentionSourceConsul, + }, + } + req.Token = token + var resp structs.IntentionQueryTestResponse + require.Nil(msgpackrpc.CallWithCodec(codec, "Intention.Test", req, &resp)) + require.False(resp.Allowed) + } +} diff --git a/agent/structs/intention.go b/agent/structs/intention.go index 5c6b1e991..34d15d997 100644 --- a/agent/structs/intention.go +++ b/agent/structs/intention.go @@ -261,6 +261,10 @@ type IntentionQueryRequest struct { // resolving wildcards. Match *IntentionQueryMatch + // Test is non-nil if we're performing a test query. A test will + // return allowed/deny based on an exact match. + Test *IntentionQueryTest + // Options for queries QueryOptions } @@ -313,6 +317,30 @@ type IntentionMatchEntry struct { Name string } +// IntentionQueryTest are the parameters for performing a test request. +type IntentionQueryTest struct { + // SourceNS, SourceName, DestinationNS, and DestinationName are the + // source and namespace, respectively, for the test. These must be + // exact values. + SourceNS, SourceName string + DestinationNS, DestinationName string + + // SourceType is the type of the value for the source. + SourceType IntentionSourceType +} + +// GetACLPrefix returns the prefix to look up the ACL policy for this +// request, and a boolean noting whether the prefix is valid to check +// or not. You must check the ok value before using the prefix. +func (q *IntentionQueryTest) GetACLPrefix() (string, bool) { + return q.DestinationName, q.DestinationName != "" +} + +// IntentionQueryTestResponse is the response for a test request. +type IntentionQueryTestResponse struct { + Allowed bool +} + // IntentionPrecedenceSorter takes a list of intentions and sorts them // based on the match precedence rules for intentions. The intentions // closer to the head of the list have higher precedence. i.e. index 0 has