Refactor IntentionDecision

This enables it to be called for many upstreams or downstreams of a
service while only querying intentions once.

Additionally, decisions are now optionally denied due to L7 permissions
being present. This enables the function to be used to filter for
potential upstreams/downstreams of a service.
This commit is contained in:
freddygv 2021-03-12 21:59:47 -07:00
parent 3d85c29445
commit 4976c000b7
4 changed files with 300 additions and 77 deletions

View file

@ -8,7 +8,6 @@ import (
"github.com/armon/go-metrics" "github.com/armon/go-metrics"
"github.com/armon/go-metrics/prometheus" "github.com/armon/go-metrics/prometheus"
"github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/agent/connect"
"github.com/hashicorp/consul/agent/consul/state" "github.com/hashicorp/consul/agent/consul/state"
"github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/lib" "github.com/hashicorp/consul/lib"
@ -684,19 +683,6 @@ func (s *Intention) Check(args *structs.IntentionQueryRequest, reply *structs.In
return fmt.Errorf("Invalid destination namespace %q: %v", query.DestinationNS, err) return fmt.Errorf("Invalid destination namespace %q: %v", query.DestinationNS, err)
} }
// 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)
}
// Perform the ACL check. For Check we only require ServiceRead and // Perform the ACL check. For Check we only require ServiceRead and
// NOT IntentionRead because the Check API only returns pass/fail and // NOT IntentionRead because the Check API only returns pass/fail and
// returns no other information about the intentions used. We could check // returns no other information about the intentions used. We could check
@ -732,7 +718,17 @@ func (s *Intention) Check(args *structs.IntentionQueryRequest, reply *structs.In
} }
state := s.srv.fsm.State() state := s.srv.fsm.State()
decision, err := state.IntentionDecision(uri, query.DestinationName, query.DestinationNS, defaultDecision)
entry := structs.IntentionMatchEntry{
Namespace: query.SourceNS,
Name: query.SourceName,
}
_, intentions, err := state.IntentionMatchOne(nil, entry, structs.IntentionMatchSource)
if err != nil {
return fmt.Errorf("failed to query intentions for %s/%s", query.SourceNS, query.SourceName)
}
decision, err := state.IntentionDecision(query.DestinationName, query.DestinationNS, intentions, structs.IntentionMatchDestination, defaultDecision, false)
if err != nil { if err != nil {
return fmt.Errorf("failed to get intention decision from (%s/%s) to (%s/%s): %v", return fmt.Errorf("failed to get intention decision from (%s/%s) to (%s/%s): %v",
query.SourceNS, query.SourceName, query.DestinationNS, query.DestinationName, err) query.SourceNS, query.SourceName, query.DestinationNS, query.DestinationName, err)

View file

@ -11,7 +11,6 @@ import (
"github.com/mitchellh/copystructure" "github.com/mitchellh/copystructure"
"github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/agent/connect"
"github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/api" "github.com/hashicorp/consul/api"
"github.com/hashicorp/consul/lib" "github.com/hashicorp/consul/lib"
@ -2859,16 +2858,20 @@ func (s *Store) ServiceTopology(
upstreamDecisions := make(map[string]structs.IntentionDecisionSummary) upstreamDecisions := make(map[string]structs.IntentionDecisionSummary)
// The given service is the source relative to upstreams matchEntry := structs.IntentionMatchEntry{
sourceURI := connect.SpiffeIDService{
Namespace: entMeta.NamespaceOrDefault(), Namespace: entMeta.NamespaceOrDefault(),
Service: service, Name: service,
}
// The given service is a source relative to its upstreams
_, intentions, err := s.IntentionMatchOne(ws, matchEntry, structs.IntentionMatchSource)
if err != nil {
return 0, nil, fmt.Errorf("failed to query intentions for %s", sn.String())
} }
for _, un := range upstreamNames { for _, un := range upstreamNames {
decision, err := s.IntentionDecision(&sourceURI, un.Name, un.NamespaceOrDefault(), defaultAllow) decision, err := s.IntentionDecision(un.Name, un.NamespaceOrDefault(), intentions, structs.IntentionMatchDestination, defaultAllow, false)
if err != nil { if err != nil {
return 0, nil, fmt.Errorf("failed to get intention decision from (%s/%s) to (%s/%s): %v", return 0, nil, fmt.Errorf("failed to get intention decision from (%s) to (%s): %v",
sourceURI.Namespace, sourceURI.Service, un.Name, un.NamespaceOrDefault(), err) sn.String(), un.String(), err)
} }
upstreamDecisions[un.String()] = decision upstreamDecisions[un.String()] = decision
} }
@ -2888,17 +2891,17 @@ func (s *Store) ServiceTopology(
maxIdx = idx maxIdx = idx
} }
// The given service is a destination relative to its downstreams
_, intentions, err = s.IntentionMatchOne(ws, matchEntry, structs.IntentionMatchDestination)
if err != nil {
return 0, nil, fmt.Errorf("failed to query intentions for %s", sn.String())
}
downstreamDecisions := make(map[string]structs.IntentionDecisionSummary) downstreamDecisions := make(map[string]structs.IntentionDecisionSummary)
for _, dn := range downstreamNames { for _, dn := range downstreamNames {
// Downstreams are the source relative to the given service decision, err := s.IntentionDecision(dn.Name, dn.NamespaceOrDefault(), intentions, structs.IntentionMatchSource, defaultAllow, false)
sourceURI := connect.SpiffeIDService{
Namespace: dn.NamespaceOrDefault(),
Service: dn.Name,
}
decision, err := s.IntentionDecision(&sourceURI, service, entMeta.NamespaceOrDefault(), defaultAllow)
if err != nil { if err != nil {
return 0, nil, fmt.Errorf("failed to get intention decision from (%s/%s) to (%s/%s): %v", return 0, nil, fmt.Errorf("failed to get intention decision from (%s) to (%s): %v",
sourceURI.Namespace, sourceURI.Service, service, dn.NamespaceOrDefault(), err) dn.String(), sn.String(), err)
} }
downstreamDecisions[dn.String()] = decision downstreamDecisions[dn.String()] = decision
} }

View file

@ -8,7 +8,6 @@ import (
"github.com/hashicorp/go-memdb" "github.com/hashicorp/go-memdb"
"github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/agent/connect"
"github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/agent/structs"
) )
@ -732,35 +731,19 @@ func (s *Store) LegacyIntentionDeleteAll(idx uint64) error {
return tx.Commit() return tx.Commit()
} }
// IntentionDecision returns whether a connection should be allowed from a source URI to some destination // IntentionDecision returns whether a connection should be allowed to a source or destination given a set of intentions.
// It returns true or false for the enforcement, and also a boolean for whether //
// allowPermissions determines whether the presence of L7 permissions leads to a DENY decision.
// This should be false when evaluating a connection between a source and destination, but not the request that will be sent.
func (s *Store) IntentionDecision( func (s *Store) IntentionDecision(
srcURI connect.CertURI, dstName, dstNS string, defaultDecision acl.EnforcementDecision, target, targetNS string, intentions structs.Intentions, matchType structs.IntentionMatchType,
defaultDecision acl.EnforcementDecision, allowPermissions bool,
) (structs.IntentionDecisionSummary, error) { ) (structs.IntentionDecisionSummary, error) {
_, matches, err := s.IntentionMatch(nil, &structs.IntentionQueryMatch{
Type: structs.IntentionMatchDestination,
Entries: []structs.IntentionMatchEntry{
{
Namespace: dstNS,
Name: dstName,
},
},
})
if err != nil {
return structs.IntentionDecisionSummary{}, 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 structs.IntentionDecisionSummary{}, errors.New("internal error loading matches")
}
// Figure out which source matches this request. // Figure out which source matches this request.
var ixnMatch *structs.Intention var ixnMatch *structs.Intention
for _, ixn := range matches[0] { for _, ixn := range intentions {
if _, ok := srcURI.Authorize(ixn); ok { if _, ok := AuthorizeIntentionTarget(target, targetNS, ixn, matchType); ok {
ixnMatch = ixn ixnMatch = ixn
break break
} }
@ -778,7 +761,7 @@ func (s *Store) IntentionDecision(
if len(ixnMatch.Permissions) > 0 { if len(ixnMatch.Permissions) > 0 {
// If there are L7 permissions, DENY. // If there are L7 permissions, DENY.
// We are only evaluating source and destination, not the request that will be sent. // We are only evaluating source and destination, not the request that will be sent.
resp.Allowed = false resp.Allowed = allowPermissions
resp.HasPermissions = true resp.HasPermissions = true
} }
resp.ExternalSource = ixnMatch.Meta[structs.MetaExternalSource] resp.ExternalSource = ixnMatch.Meta[structs.MetaExternalSource]
@ -792,6 +775,37 @@ func (s *Store) IntentionDecision(
return resp, nil return resp, nil
} }
// AuthorizeIntentionTarget determines whether the destination is covered by the given intention
// and whether the intention action allows a connection.
func AuthorizeIntentionTarget(target, targetNS string, ixn *structs.Intention, matchType structs.IntentionMatchType) (bool, bool) {
switch matchType {
case structs.IntentionMatchDestination:
if ixn.DestinationNS != structs.WildcardSpecifier && ixn.DestinationNS != targetNS {
// Non-matching namespace
return false, false
}
if ixn.DestinationName != structs.WildcardSpecifier && ixn.DestinationName != target {
// Non-matching name
return false, false
}
case structs.IntentionMatchSource:
if ixn.SourceNS != structs.WildcardSpecifier && ixn.SourceNS != targetNS {
// Non-matching namespace
return false, false
}
if ixn.SourceName != structs.WildcardSpecifier && ixn.SourceName != target {
// Non-matching name
return false, false
}
}
// The name and namespace match, so the destination is covered
return ixn.Action == structs.IntentionActionAllow, true
}
// IntentionMatch returns the list of intentions that match the namespace and // IntentionMatch returns the list of intentions that match the namespace and
// name for either a source or destination. This applies the resolution rules // name for either a source or destination. This applies the resolution rules
// so wildcards will match any value. // so wildcards will match any value.

View file

@ -9,7 +9,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/agent/connect"
"github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/sdk/testutil" "github.com/hashicorp/consul/sdk/testutil"
) )
@ -1760,16 +1759,19 @@ func TestStore_IntentionDecision(t *testing.T) {
} }
tt := []struct { tt := []struct {
name string name string
src string src string
dst string dst string
defaultDecision acl.EnforcementDecision matchType structs.IntentionMatchType
expect structs.IntentionDecisionSummary defaultDecision acl.EnforcementDecision
allowPermissions bool
expect structs.IntentionDecisionSummary
}{ }{
{ {
name: "no matching intention and default deny", name: "no matching intention and default deny",
src: "does-not-exist", src: "does-not-exist",
dst: "ditto", dst: "ditto",
matchType: structs.IntentionMatchDestination,
defaultDecision: acl.Deny, defaultDecision: acl.Deny,
expect: structs.IntentionDecisionSummary{Allowed: false}, expect: structs.IntentionDecisionSummary{Allowed: false},
}, },
@ -1777,13 +1779,15 @@ func TestStore_IntentionDecision(t *testing.T) {
name: "no matching intention and default allow", name: "no matching intention and default allow",
src: "does-not-exist", src: "does-not-exist",
dst: "ditto", dst: "ditto",
matchType: structs.IntentionMatchDestination,
defaultDecision: acl.Allow, defaultDecision: acl.Allow,
expect: structs.IntentionDecisionSummary{Allowed: true}, expect: structs.IntentionDecisionSummary{Allowed: true},
}, },
{ {
name: "denied with permissions", name: "denied with permissions",
src: "web", src: "web",
dst: "redis", dst: "redis",
matchType: structs.IntentionMatchDestination,
expect: structs.IntentionDecisionSummary{ expect: structs.IntentionDecisionSummary{
Allowed: false, Allowed: false,
HasPermissions: true, HasPermissions: true,
@ -1791,9 +1795,22 @@ func TestStore_IntentionDecision(t *testing.T) {
}, },
}, },
{ {
name: "denied without permissions", name: "allowed with permissions",
src: "api", src: "web",
dst: "redis", dst: "redis",
allowPermissions: true,
matchType: structs.IntentionMatchDestination,
expect: structs.IntentionDecisionSummary{
Allowed: true,
HasPermissions: true,
HasExact: true,
},
},
{
name: "denied without permissions",
src: "api",
dst: "redis",
matchType: structs.IntentionMatchDestination,
expect: structs.IntentionDecisionSummary{ expect: structs.IntentionDecisionSummary{
Allowed: false, Allowed: false,
HasPermissions: false, HasPermissions: false,
@ -1801,9 +1818,10 @@ func TestStore_IntentionDecision(t *testing.T) {
}, },
}, },
{ {
name: "allowed from external source", name: "allowed from external source",
src: "api", src: "api",
dst: "web", dst: "web",
matchType: structs.IntentionMatchDestination,
expect: structs.IntentionDecisionSummary{ expect: structs.IntentionDecisionSummary{
Allowed: true, Allowed: true,
HasPermissions: false, HasPermissions: false,
@ -1812,9 +1830,21 @@ func TestStore_IntentionDecision(t *testing.T) {
}, },
}, },
{ {
name: "allowed by source wildcard not exact", name: "allowed by source wildcard not exact",
src: "anything", src: "anything",
dst: "mysql", dst: "mysql",
matchType: structs.IntentionMatchDestination,
expect: structs.IntentionDecisionSummary{
Allowed: true,
HasPermissions: false,
HasExact: false,
},
},
{
name: "allowed by matching on source",
src: "web",
dst: "api",
matchType: structs.IntentionMatchSource,
expect: structs.IntentionDecisionSummary{ expect: structs.IntentionDecisionSummary{
Allowed: true, Allowed: true,
HasPermissions: false, HasPermissions: false,
@ -1824,17 +1854,197 @@ func TestStore_IntentionDecision(t *testing.T) {
} }
for _, tc := range tt { for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
uri := connect.SpiffeIDService{ entry := structs.IntentionMatchEntry{
Service: tc.src,
Namespace: structs.IntentionDefaultNamespace, Namespace: structs.IntentionDefaultNamespace,
Name: tc.src,
} }
decision, err := s.IntentionDecision(&uri, tc.dst, structs.IntentionDefaultNamespace, tc.defaultDecision) _, intentions, err := s.IntentionMatchOne(nil, entry, structs.IntentionMatchSource)
if err != nil {
require.NoError(t, err)
}
decision, err := s.IntentionDecision(tc.dst, structs.IntentionDefaultNamespace, intentions, tc.matchType, tc.defaultDecision, tc.allowPermissions)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, tc.expect, decision) require.Equal(t, tc.expect, decision)
}) })
} }
} }
func TestAuthorizeIntentionTarget(t *testing.T) {
cases := []struct {
name string
target string
targetNS string
ixn *structs.Intention
matchType structs.IntentionMatchType
auth bool
match bool
}{
// Source match type
{
name: "match exact source, not matching namespace",
target: "web",
targetNS: structs.IntentionDefaultNamespace,
ixn: &structs.Intention{
SourceName: "db",
SourceNS: "different",
},
matchType: structs.IntentionMatchSource,
auth: false,
match: false,
},
{
name: "match exact source, not matching name",
target: "web",
targetNS: structs.IntentionDefaultNamespace,
ixn: &structs.Intention{
SourceName: "db",
SourceNS: structs.IntentionDefaultNamespace,
},
matchType: structs.IntentionMatchSource,
auth: false,
match: false,
},
{
name: "match exact source, allow",
target: "web",
targetNS: structs.IntentionDefaultNamespace,
ixn: &structs.Intention{
SourceName: "web",
SourceNS: structs.IntentionDefaultNamespace,
Action: structs.IntentionActionAllow,
},
matchType: structs.IntentionMatchSource,
auth: true,
match: true,
},
{
name: "match exact source, deny",
target: "web",
targetNS: structs.IntentionDefaultNamespace,
ixn: &structs.Intention{
SourceName: "web",
SourceNS: structs.IntentionDefaultNamespace,
Action: structs.IntentionActionDeny,
},
matchType: structs.IntentionMatchSource,
auth: false,
match: true,
},
{
name: "match exact sourceNS for wildcard service, deny",
target: "web",
targetNS: structs.IntentionDefaultNamespace,
ixn: &structs.Intention{
SourceName: structs.WildcardSpecifier,
SourceNS: structs.IntentionDefaultNamespace,
Action: structs.IntentionActionDeny,
},
matchType: structs.IntentionMatchSource,
auth: false,
match: true,
},
{
name: "match exact sourceNS for wildcard service, allow",
target: "web",
targetNS: structs.IntentionDefaultNamespace,
ixn: &structs.Intention{
SourceName: structs.WildcardSpecifier,
SourceNS: structs.IntentionDefaultNamespace,
Action: structs.IntentionActionAllow,
},
matchType: structs.IntentionMatchSource,
auth: true,
match: true,
},
// Destination match type
{
name: "match exact destination, not matching namespace",
target: "web",
targetNS: structs.IntentionDefaultNamespace,
ixn: &structs.Intention{
DestinationName: "db",
DestinationNS: "different",
},
matchType: structs.IntentionMatchDestination,
auth: false,
match: false,
},
{
name: "match exact destination, not matching name",
target: "web",
targetNS: structs.IntentionDefaultNamespace,
ixn: &structs.Intention{
DestinationName: "db",
DestinationNS: structs.IntentionDefaultNamespace,
},
matchType: structs.IntentionMatchDestination,
auth: false,
match: false,
},
{
name: "match exact destination, allow",
target: "web",
targetNS: structs.IntentionDefaultNamespace,
ixn: &structs.Intention{
DestinationName: "web",
DestinationNS: structs.IntentionDefaultNamespace,
Action: structs.IntentionActionAllow,
},
matchType: structs.IntentionMatchDestination,
auth: true,
match: true,
},
{
name: "match exact destination, deny",
target: "web",
targetNS: structs.IntentionDefaultNamespace,
ixn: &structs.Intention{
DestinationName: "web",
DestinationNS: structs.IntentionDefaultNamespace,
Action: structs.IntentionActionDeny,
},
matchType: structs.IntentionMatchDestination,
auth: false,
match: true,
},
{
name: "match exact destinationNS for wildcard service, deny",
target: "web",
targetNS: structs.IntentionDefaultNamespace,
ixn: &structs.Intention{
DestinationName: structs.WildcardSpecifier,
DestinationNS: structs.IntentionDefaultNamespace,
Action: structs.IntentionActionDeny,
},
matchType: structs.IntentionMatchDestination,
auth: false,
match: true,
},
{
name: "match exact destinationNS for wildcard service, allow",
target: "web",
targetNS: structs.IntentionDefaultNamespace,
ixn: &structs.Intention{
DestinationName: structs.WildcardSpecifier,
DestinationNS: structs.IntentionDefaultNamespace,
Action: structs.IntentionActionAllow,
},
matchType: structs.IntentionMatchDestination,
auth: true,
match: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
auth, match := AuthorizeIntentionTarget(tc.target, tc.targetNS, tc.ixn, tc.matchType)
assert.Equal(t, tc.auth, auth)
assert.Equal(t, tc.match, match)
})
}
}
func disableLegacyIntentions(s *Store) error { func disableLegacyIntentions(s *Store) error {
return s.SystemMetadataSet(1, &structs.SystemMetadataEntry{ return s.SystemMetadataSet(1, &structs.SystemMetadataEntry{
Key: structs.SystemMetadataIntentionFormatKey, Key: structs.SystemMetadataIntentionFormatKey,