mirror of https://github.com/status-im/consul.git
Return intention info in svc topology endpoint (#8853)
This commit is contained in:
parent
6c0907f494
commit
da91e999f6
|
@ -125,13 +125,9 @@ func (a *Agent) ConnectAuthorize(token string,
|
|||
}
|
||||
|
||||
// No match, we need to determine the default behavior. We do this by
|
||||
// specifying the anonymous token, which will get the default behavior. The
|
||||
// fetching the default intention behavior from the resolved authorizer. 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.
|
||||
authz, err = a.resolveToken("")
|
||||
if err != nil {
|
||||
return returnErr(err)
|
||||
}
|
||||
if authz == nil {
|
||||
// ACLs not enabled at all, the default is allow all.
|
||||
return true, "ACLs disabled, access is allowed by default", &meta, nil
|
||||
|
|
|
@ -844,4 +844,59 @@ func registerTestTopologyEntries(t *testing.T, codec rpc.ClientCodec, token stri
|
|||
},
|
||||
}
|
||||
registerTestCatalogEntriesMap(t, codec, registrations)
|
||||
|
||||
// Add intentions: deny all, web -> redis with L7 perms, but omit intention for api -> web
|
||||
entries := []structs.ConfigEntryRequest{
|
||||
{
|
||||
Datacenter: "dc1",
|
||||
Entry: &structs.ProxyConfigEntry{
|
||||
Kind: structs.ProxyDefaults,
|
||||
Name: structs.ProxyConfigGlobal,
|
||||
Config: map[string]interface{}{
|
||||
"protocol": "http",
|
||||
},
|
||||
},
|
||||
WriteRequest: structs.WriteRequest{Token: token},
|
||||
},
|
||||
{
|
||||
Datacenter: "dc1",
|
||||
Entry: &structs.ServiceIntentionsConfigEntry{
|
||||
Kind: structs.ServiceIntentions,
|
||||
Name: "redis",
|
||||
Sources: []*structs.SourceIntention{
|
||||
{
|
||||
Name: "web",
|
||||
Permissions: []*structs.IntentionPermission{
|
||||
{
|
||||
Action: structs.IntentionActionAllow,
|
||||
HTTP: &structs.IntentionHTTPPermission{
|
||||
Methods: []string{"GET"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
WriteRequest: structs.WriteRequest{Token: token},
|
||||
},
|
||||
{
|
||||
Datacenter: "dc1",
|
||||
Entry: &structs.ServiceIntentionsConfigEntry{
|
||||
Kind: structs.ServiceIntentions,
|
||||
Name: "*",
|
||||
Meta: map[string]string{structs.MetaExternalSource: "nomad"},
|
||||
Sources: []*structs.SourceIntention{
|
||||
{
|
||||
Name: "*",
|
||||
Action: structs.IntentionActionDeny,
|
||||
},
|
||||
},
|
||||
},
|
||||
WriteRequest: structs.WriteRequest{Token: token},
|
||||
},
|
||||
}
|
||||
for _, req := range entries {
|
||||
var out bool
|
||||
require.NoError(t, msgpackrpc.CallWithCodec(codec, "ConfigEntry.Apply", &req, &out))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -783,53 +783,11 @@ func (s *Intention) Check(
|
|||
}
|
||||
}
|
||||
|
||||
// Get the matches for this destination
|
||||
state := s.srv.fsm.State()
|
||||
_, matches, err := state.IntentionMatch(nil, &structs.IntentionQueryMatch{
|
||||
Type: structs.IntentionMatchDestination,
|
||||
Entries: []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")
|
||||
}
|
||||
|
||||
// Figure out which source matches this request.
|
||||
var ixnMatch *structs.Intention
|
||||
for _, ixn := range matches[0] {
|
||||
if _, ok := uri.Authorize(ixn); ok {
|
||||
ixnMatch = ixn
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ixnMatch != nil {
|
||||
if len(ixnMatch.Permissions) == 0 {
|
||||
// This is an L4 intention.
|
||||
reply.Allowed = ixnMatch.Action == structs.IntentionActionAllow
|
||||
return nil
|
||||
}
|
||||
|
||||
// This is an L7 intention, so DENY.
|
||||
reply.Allowed = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// Note: the default intention policy is like an intention with a
|
||||
// wildcarded destination in that it is limited to L4-only.
|
||||
|
||||
// No match, we need to determine the default behavior. We do this by
|
||||
// specifying the anonymous token token, which will get that behavior.
|
||||
// fetching the default intention behavior from the resolved authorizer.
|
||||
// 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.
|
||||
|
@ -837,15 +795,18 @@ func (s *Intention) Check(
|
|||
// 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.
|
||||
authz, err = s.srv.ResolveToken("")
|
||||
if err != nil {
|
||||
return err
|
||||
defaultDecision := acl.Allow
|
||||
if authz != nil {
|
||||
defaultDecision = authz.IntentionDefaultAllow(nil)
|
||||
}
|
||||
|
||||
reply.Allowed = true
|
||||
if authz != nil {
|
||||
reply.Allowed = authz.IntentionDefaultAllow(nil) == acl.Allow
|
||||
state := s.srv.fsm.State()
|
||||
decision, err := state.IntentionDecision(uri, query.DestinationName, query.DestinationNS, defaultDecision)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get intention decision from (%s/%s) to (%s/%s): %v",
|
||||
query.SourceNS, query.SourceName, query.DestinationNS, query.DestinationName, err)
|
||||
}
|
||||
reply.Allowed = decision.Allowed
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -168,7 +168,12 @@ func (m *Internal) ServiceTopology(args *structs.ServiceSpecificRequest, reply *
|
|||
&args.QueryOptions,
|
||||
&reply.QueryMeta,
|
||||
func(ws memdb.WatchSet, state *state.Store) error {
|
||||
index, topology, err := state.ServiceTopology(ws, args.Datacenter, args.ServiceName, &args.EnterpriseMeta)
|
||||
defaultAllow := acl.Allow
|
||||
if authz != nil {
|
||||
defaultAllow = authz.IntentionDefaultAllow(nil)
|
||||
}
|
||||
|
||||
index, topology, err := state.ServiceTopology(ws, args.Datacenter, args.ServiceName, defaultAllow, &args.EnterpriseMeta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/hashicorp/consul/agent/structs"
|
||||
"github.com/hashicorp/consul/api"
|
||||
"github.com/hashicorp/consul/lib/stringslice"
|
||||
"github.com/hashicorp/consul/sdk/testutil/retry"
|
||||
"github.com/hashicorp/consul/testrpc"
|
||||
"github.com/hashicorp/consul/types"
|
||||
msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc"
|
||||
|
@ -1623,49 +1624,95 @@ func TestInternal_ServiceTopology(t *testing.T) {
|
|||
// redis and redis-proxy on node zip
|
||||
registerTestTopologyEntries(t, codec, "")
|
||||
|
||||
t.Run("api", func(t *testing.T) {
|
||||
args := structs.ServiceSpecificRequest{
|
||||
Datacenter: "dc1",
|
||||
ServiceName: "api",
|
||||
}
|
||||
var out structs.IndexedServiceTopology
|
||||
require.NoError(t, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
require.False(t, out.FilteredByACLs)
|
||||
var (
|
||||
api = structs.NewServiceName("api", structs.DefaultEnterpriseMeta())
|
||||
web = structs.NewServiceName("web", structs.DefaultEnterpriseMeta())
|
||||
redis = structs.NewServiceName("redis", structs.DefaultEnterpriseMeta())
|
||||
)
|
||||
|
||||
// bar/web, bar/web-proxy, baz/web, baz/web-proxy
|
||||
require.Len(t, out.ServiceTopology.Upstreams, 4)
|
||||
require.Len(t, out.ServiceTopology.Downstreams, 0)
|
||||
t.Run("api", func(t *testing.T) {
|
||||
retry.Run(t, func(r *retry.R) {
|
||||
args := structs.ServiceSpecificRequest{
|
||||
Datacenter: "dc1",
|
||||
ServiceName: "api",
|
||||
}
|
||||
var out structs.IndexedServiceTopology
|
||||
require.NoError(r, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
require.False(r, out.FilteredByACLs)
|
||||
|
||||
// bar/web, bar/web-proxy, baz/web, baz/web-proxy
|
||||
require.Len(r, out.ServiceTopology.Upstreams, 4)
|
||||
require.Len(r, out.ServiceTopology.Downstreams, 0)
|
||||
|
||||
expectUp := map[string]structs.IntentionDecisionSummary{
|
||||
web.String(): {
|
||||
Allowed: false,
|
||||
HasPermissions: false,
|
||||
ExternalSource: "nomad",
|
||||
},
|
||||
}
|
||||
require.Equal(r, expectUp, out.ServiceTopology.UpstreamDecisions)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("web", func(t *testing.T) {
|
||||
args := structs.ServiceSpecificRequest{
|
||||
Datacenter: "dc1",
|
||||
ServiceName: "web",
|
||||
}
|
||||
var out structs.IndexedServiceTopology
|
||||
require.NoError(t, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
require.False(t, out.FilteredByACLs)
|
||||
retry.Run(t, func(r *retry.R) {
|
||||
args := structs.ServiceSpecificRequest{
|
||||
Datacenter: "dc1",
|
||||
ServiceName: "web",
|
||||
}
|
||||
var out structs.IndexedServiceTopology
|
||||
require.NoError(r, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
require.False(r, out.FilteredByACLs)
|
||||
|
||||
// foo/api, foo/api-proxy
|
||||
require.Len(t, out.ServiceTopology.Upstreams, 2)
|
||||
// foo/api, foo/api-proxy
|
||||
require.Len(r, out.ServiceTopology.Downstreams, 2)
|
||||
|
||||
// zip/redis, zip/redis-proxy
|
||||
require.Len(t, out.ServiceTopology.Downstreams, 2)
|
||||
expectDown := map[string]structs.IntentionDecisionSummary{
|
||||
api.String(): {
|
||||
Allowed: false,
|
||||
HasPermissions: false,
|
||||
ExternalSource: "nomad",
|
||||
},
|
||||
}
|
||||
require.Equal(r, expectDown, out.ServiceTopology.DownstreamDecisions)
|
||||
|
||||
// zip/redis, zip/redis-proxy
|
||||
require.Len(r, out.ServiceTopology.Upstreams, 2)
|
||||
|
||||
expectUp := map[string]structs.IntentionDecisionSummary{
|
||||
redis.String(): {
|
||||
Allowed: false,
|
||||
HasPermissions: true,
|
||||
},
|
||||
}
|
||||
require.Equal(r, expectUp, out.ServiceTopology.UpstreamDecisions)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("redis", func(t *testing.T) {
|
||||
args := structs.ServiceSpecificRequest{
|
||||
Datacenter: "dc1",
|
||||
ServiceName: "redis",
|
||||
}
|
||||
var out structs.IndexedServiceTopology
|
||||
require.NoError(t, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
require.False(t, out.FilteredByACLs)
|
||||
retry.Run(t, func(r *retry.R) {
|
||||
args := structs.ServiceSpecificRequest{
|
||||
Datacenter: "dc1",
|
||||
ServiceName: "redis",
|
||||
}
|
||||
var out structs.IndexedServiceTopology
|
||||
require.NoError(r, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out))
|
||||
require.False(r, out.FilteredByACLs)
|
||||
|
||||
require.Len(t, out.ServiceTopology.Upstreams, 0)
|
||||
require.Len(r, out.ServiceTopology.Upstreams, 0)
|
||||
|
||||
// bar/web, bar/web-proxy, baz/web, baz/web-proxy
|
||||
require.Len(t, out.ServiceTopology.Downstreams, 4)
|
||||
// bar/web, bar/web-proxy, baz/web, baz/web-proxy
|
||||
require.Len(r, out.ServiceTopology.Downstreams, 4)
|
||||
|
||||
expectDown := map[string]structs.IntentionDecisionSummary{
|
||||
web.String(): {
|
||||
Allowed: false,
|
||||
HasPermissions: true,
|
||||
},
|
||||
}
|
||||
require.Equal(r, expectDown, out.ServiceTopology.DownstreamDecisions)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,8 @@ import (
|
|||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/consul/acl"
|
||||
"github.com/hashicorp/consul/agent/connect"
|
||||
"github.com/hashicorp/consul/agent/structs"
|
||||
"github.com/hashicorp/consul/api"
|
||||
"github.com/hashicorp/consul/lib"
|
||||
|
@ -2911,6 +2913,7 @@ func checkProtocolMatch(tx ReadTxn, ws memdb.WatchSet, svc *structs.GatewayServi
|
|||
func (s *Store) ServiceTopology(
|
||||
ws memdb.WatchSet,
|
||||
dc, service string,
|
||||
defaultAllow acl.EnforcementDecision,
|
||||
entMeta *structs.EnterpriseMeta,
|
||||
) (uint64, *structs.ServiceTopology, error) {
|
||||
tx := s.db.ReadTxn()
|
||||
|
@ -2936,6 +2939,22 @@ func (s *Store) ServiceTopology(
|
|||
maxIdx = idx
|
||||
}
|
||||
|
||||
upstreamDecisions := make(map[string]structs.IntentionDecisionSummary)
|
||||
|
||||
// The given service is the source relative to upstreams
|
||||
sourceURI := connect.SpiffeIDService{
|
||||
Namespace: entMeta.NamespaceOrDefault(),
|
||||
Service: service,
|
||||
}
|
||||
for _, un := range upstreamNames {
|
||||
decision, err := s.IntentionDecision(&sourceURI, un.Name, un.NamespaceOrDefault(), defaultAllow)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("failed to get intention decision from (%s/%s) to (%s/%s): %v",
|
||||
sourceURI.Namespace, sourceURI.Service, un.Name, un.NamespaceOrDefault(), err)
|
||||
}
|
||||
upstreamDecisions[un.String()] = decision
|
||||
}
|
||||
|
||||
idx, downstreamNames, err := s.downstreamsForServiceTxn(tx, ws, dc, sn)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
|
@ -2951,9 +2970,26 @@ func (s *Store) ServiceTopology(
|
|||
maxIdx = idx
|
||||
}
|
||||
|
||||
downstreamDecisions := make(map[string]structs.IntentionDecisionSummary)
|
||||
for _, dn := range downstreamNames {
|
||||
// Downstreams are the source relative to the given service
|
||||
sourceURI := connect.SpiffeIDService{
|
||||
Namespace: dn.NamespaceOrDefault(),
|
||||
Service: dn.Name,
|
||||
}
|
||||
decision, err := s.IntentionDecision(&sourceURI, service, entMeta.NamespaceOrDefault(), defaultAllow)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("failed to get intention decision from (%s/%s) to (%s/%s): %v",
|
||||
sourceURI.Namespace, sourceURI.Service, service, dn.NamespaceOrDefault(), err)
|
||||
}
|
||||
downstreamDecisions[dn.String()] = decision
|
||||
}
|
||||
|
||||
resp := &structs.ServiceTopology{
|
||||
Upstreams: upstreams,
|
||||
Downstreams: downstreams,
|
||||
Upstreams: upstreams,
|
||||
Downstreams: downstreams,
|
||||
UpstreamDecisions: upstreamDecisions,
|
||||
DownstreamDecisions: downstreamDecisions,
|
||||
}
|
||||
return maxIdx, resp, nil
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ import (
|
|||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/hashicorp/consul/acl"
|
||||
"github.com/hashicorp/consul/agent/connect"
|
||||
"github.com/hashicorp/consul/agent/structs"
|
||||
"github.com/hashicorp/go-memdb"
|
||||
)
|
||||
|
@ -447,6 +449,60 @@ func (s *Store) LegacyIntentionDeleteAll(idx uint64) error {
|
|||
return tx.Commit()
|
||||
}
|
||||
|
||||
// IntentionDecision returns whether a connection should be allowed from a source URI to some destination
|
||||
// It returns true or false for the enforcement, and also a boolean for whether
|
||||
func (s *Store) IntentionDecision(
|
||||
srcURI connect.CertURI, dstName, dstNS string, defaultDecision acl.EnforcementDecision,
|
||||
) (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.
|
||||
var ixnMatch *structs.Intention
|
||||
for _, ixn := range matches[0] {
|
||||
if _, ok := srcURI.Authorize(ixn); ok {
|
||||
ixnMatch = ixn
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var resp structs.IntentionDecisionSummary
|
||||
if ixnMatch == nil {
|
||||
// No intention found, fall back to default
|
||||
resp.Allowed = defaultDecision == acl.Allow
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Intention found, combine action + permissions
|
||||
resp.Allowed = ixnMatch.Action == structs.IntentionActionAllow
|
||||
if len(ixnMatch.Permissions) > 0 {
|
||||
// If there are L7 permissions, DENY.
|
||||
// We are only evaluating source and destination, not the request that will be sent.
|
||||
resp.Allowed = false
|
||||
resp.HasPermissions = true
|
||||
}
|
||||
resp.ExternalSource = ixnMatch.Meta[structs.MetaExternalSource]
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// IntentionMatch returns the list of intentions that match the namespace and
|
||||
// name for either a source or destination. This applies the resolution rules
|
||||
// so wildcards will match any value.
|
||||
|
|
|
@ -4,6 +4,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/consul/acl"
|
||||
"github.com/hashicorp/consul/agent/connect"
|
||||
"github.com/hashicorp/consul/agent/structs"
|
||||
"github.com/hashicorp/consul/sdk/testutil"
|
||||
"github.com/hashicorp/go-memdb"
|
||||
|
@ -1083,6 +1085,123 @@ func TestStore_LegacyIntention_Snapshot_Restore(t *testing.T) {
|
|||
}()
|
||||
}
|
||||
|
||||
// Note: This test does not have an equivalent with legacy intentions as an input.
|
||||
// That's because the config vs legacy split is handled by store.IntentionMatch
|
||||
// which has its own tests
|
||||
func TestStore_IntentionDecision(t *testing.T) {
|
||||
// web to redis allowed and with permissions
|
||||
// api to redis denied and without perms (so redis has multiple matches as destination)
|
||||
// api to web without permissions and with meta
|
||||
entries := []structs.ConfigEntry{
|
||||
&structs.ProxyConfigEntry{
|
||||
Kind: structs.ProxyDefaults,
|
||||
Name: structs.ProxyConfigGlobal,
|
||||
Config: map[string]interface{}{
|
||||
"protocol": "http",
|
||||
},
|
||||
},
|
||||
&structs.ServiceIntentionsConfigEntry{
|
||||
Kind: structs.ServiceIntentions,
|
||||
Name: "redis",
|
||||
Sources: []*structs.SourceIntention{
|
||||
{
|
||||
Name: "web",
|
||||
Permissions: []*structs.IntentionPermission{
|
||||
{
|
||||
Action: structs.IntentionActionAllow,
|
||||
HTTP: &structs.IntentionHTTPPermission{
|
||||
Methods: []string{"GET"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "api",
|
||||
Action: structs.IntentionActionDeny,
|
||||
},
|
||||
},
|
||||
},
|
||||
&structs.ServiceIntentionsConfigEntry{
|
||||
Kind: structs.ServiceIntentions,
|
||||
Name: "web",
|
||||
Meta: map[string]string{structs.MetaExternalSource: "nomad"},
|
||||
Sources: []*structs.SourceIntention{
|
||||
{
|
||||
Name: "api",
|
||||
Action: structs.IntentionActionAllow,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
s := testConfigStateStore(t)
|
||||
for _, entry := range entries {
|
||||
require.NoError(t, s.EnsureConfigEntry(1, entry, nil))
|
||||
}
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
src string
|
||||
dst string
|
||||
defaultDecision acl.EnforcementDecision
|
||||
expect structs.IntentionDecisionSummary
|
||||
}{
|
||||
{
|
||||
name: "no matching intention and default deny",
|
||||
src: "does-not-exist",
|
||||
dst: "ditto",
|
||||
defaultDecision: acl.Deny,
|
||||
expect: structs.IntentionDecisionSummary{Allowed: false},
|
||||
},
|
||||
{
|
||||
name: "no matching intention and default allow",
|
||||
src: "does-not-exist",
|
||||
dst: "ditto",
|
||||
defaultDecision: acl.Allow,
|
||||
expect: structs.IntentionDecisionSummary{Allowed: true},
|
||||
},
|
||||
{
|
||||
name: "denied with permissions",
|
||||
src: "web",
|
||||
dst: "redis",
|
||||
expect: structs.IntentionDecisionSummary{
|
||||
Allowed: false,
|
||||
HasPermissions: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "denied without permissions",
|
||||
src: "api",
|
||||
dst: "redis",
|
||||
expect: structs.IntentionDecisionSummary{
|
||||
Allowed: false,
|
||||
HasPermissions: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allowed from external source",
|
||||
src: "api",
|
||||
dst: "web",
|
||||
expect: structs.IntentionDecisionSummary{
|
||||
Allowed: true,
|
||||
HasPermissions: false,
|
||||
ExternalSource: "nomad",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
uri := connect.SpiffeIDService{
|
||||
Service: tc.src,
|
||||
Namespace: structs.IntentionDefaultNamespace,
|
||||
}
|
||||
decision, err := s.IntentionDecision(&uri, tc.dst, structs.IntentionDefaultNamespace, tc.defaultDecision)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expect, decision)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func disableLegacyIntentions(s *Store) error {
|
||||
return s.SystemMetadataSet(1, &structs.SystemMetadataEntry{
|
||||
Key: structs.SystemMetadataIntentionFormatKey,
|
||||
|
|
|
@ -641,6 +641,17 @@ type IntentionQueryCheckResponse struct {
|
|||
Allowed bool
|
||||
}
|
||||
|
||||
// IntentionDecisionSummary contains a summary of a set of intentions between two services
|
||||
// Currently contains:
|
||||
// - Whether all actions are allowed
|
||||
// - Whether the matching intention has L7 permissions attached
|
||||
// - Whether the intention is managed by an external source like k8s,
|
||||
type IntentionDecisionSummary struct {
|
||||
Allowed bool
|
||||
HasPermissions bool
|
||||
ExternalSource string
|
||||
}
|
||||
|
||||
// IntentionQueryExact holds the parameters for performing a lookup of an
|
||||
// intention by its unique name instead of its ID.
|
||||
type IntentionQueryExact struct {
|
||||
|
|
|
@ -104,6 +104,9 @@ const (
|
|||
// mesh gateway is usable for wan federation.
|
||||
MetaWANFederationKey = "consul-wan-federation"
|
||||
|
||||
// MetaExternalSource is the metadata key used when a resource is managed by a source outside Consul like nomad/k8s
|
||||
MetaExternalSource = "external-source"
|
||||
|
||||
// MaxLockDelay provides a maximum LockDelay value for
|
||||
// a session. Any value above this will not be respected.
|
||||
MaxLockDelay = 60 * time.Second
|
||||
|
@ -1867,6 +1870,9 @@ type IndexedServiceTopology struct {
|
|||
type ServiceTopology struct {
|
||||
Upstreams CheckServiceNodes
|
||||
Downstreams CheckServiceNodes
|
||||
|
||||
UpstreamDecisions map[string]IntentionDecisionSummary
|
||||
DownstreamDecisions map[string]IntentionDecisionSummary
|
||||
}
|
||||
|
||||
// IndexedConfigEntries has its own encoding logic which differs from
|
||||
|
|
|
@ -11,11 +11,6 @@ import (
|
|||
"github.com/hashicorp/consul/api"
|
||||
)
|
||||
|
||||
// metaExternalSource is the key name for the service instance meta that
|
||||
// defines the external syncing source. This is used by the UI APIs below
|
||||
// to extract this.
|
||||
const metaExternalSource = "external-source"
|
||||
|
||||
// ServiceSummary is used to summarize a service
|
||||
type ServiceSummary struct {
|
||||
Kind structs.ServiceKind `json:",omitempty"`
|
||||
|
@ -42,13 +37,6 @@ func (s *ServiceSummary) LessThan(other *ServiceSummary) bool {
|
|||
return s.Name < other.Name
|
||||
}
|
||||
|
||||
type ServiceListingSummary struct {
|
||||
ServiceSummary
|
||||
|
||||
ConnectedWithProxy bool
|
||||
ConnectedWithGateway bool
|
||||
}
|
||||
|
||||
type GatewayConfig struct {
|
||||
AssociatedServiceCount int `json:",omitempty"`
|
||||
Addresses []string `json:",omitempty"`
|
||||
|
@ -57,9 +45,22 @@ type GatewayConfig struct {
|
|||
addressesSet map[string]struct{}
|
||||
}
|
||||
|
||||
type ServiceListingSummary struct {
|
||||
ServiceSummary
|
||||
|
||||
ConnectedWithProxy bool
|
||||
ConnectedWithGateway bool
|
||||
}
|
||||
|
||||
type ServiceTopologySummary struct {
|
||||
ServiceSummary
|
||||
|
||||
Intention structs.IntentionDecisionSummary
|
||||
}
|
||||
|
||||
type ServiceTopology struct {
|
||||
Upstreams []*ServiceSummary
|
||||
Downstreams []*ServiceSummary
|
||||
Upstreams []*ServiceTopologySummary
|
||||
Downstreams []*ServiceTopologySummary
|
||||
FilteredByACLs bool
|
||||
}
|
||||
|
||||
|
@ -291,12 +292,38 @@ RPC:
|
|||
upstreams, _ := summarizeServices(out.ServiceTopology.Upstreams.ToServiceDump(), nil, "")
|
||||
downstreams, _ := summarizeServices(out.ServiceTopology.Downstreams.ToServiceDump(), nil, "")
|
||||
|
||||
sum := ServiceTopology{
|
||||
Upstreams: prepSummaryOutput(upstreams, true),
|
||||
Downstreams: prepSummaryOutput(downstreams, true),
|
||||
var (
|
||||
upstreamResp []*ServiceTopologySummary
|
||||
downstreamResp []*ServiceTopologySummary
|
||||
)
|
||||
|
||||
// Sort and attach intention data for upstreams and downstreams
|
||||
sortedUpstreams := prepSummaryOutput(upstreams, true)
|
||||
for _, svc := range sortedUpstreams {
|
||||
sn := structs.NewServiceName(svc.Name, &svc.EnterpriseMeta)
|
||||
sum := ServiceTopologySummary{
|
||||
ServiceSummary: *svc,
|
||||
Intention: out.ServiceTopology.UpstreamDecisions[sn.String()],
|
||||
}
|
||||
upstreamResp = append(upstreamResp, &sum)
|
||||
}
|
||||
|
||||
sortedDownstreams := prepSummaryOutput(downstreams, true)
|
||||
for _, svc := range sortedDownstreams {
|
||||
sn := structs.NewServiceName(svc.Name, &svc.EnterpriseMeta)
|
||||
sum := ServiceTopologySummary{
|
||||
ServiceSummary: *svc,
|
||||
Intention: out.ServiceTopology.DownstreamDecisions[sn.String()],
|
||||
}
|
||||
downstreamResp = append(downstreamResp, &sum)
|
||||
}
|
||||
|
||||
topo := ServiceTopology{
|
||||
Upstreams: upstreamResp,
|
||||
Downstreams: downstreamResp,
|
||||
FilteredByACLs: out.FilteredByACLs,
|
||||
}
|
||||
return sum, nil
|
||||
return topo, nil
|
||||
}
|
||||
|
||||
func summarizeServices(dump structs.ServiceDump, cfg *config.RuntimeConfig, dc string) (map[structs.ServiceName]*ServiceSummary, map[structs.ServiceName]bool) {
|
||||
|
@ -370,8 +397,8 @@ func summarizeServices(dump structs.ServiceDump, cfg *config.RuntimeConfig, dc s
|
|||
// sources. We only want to add unique sources so there is extra
|
||||
// accounting here with an unexported field to maintain the set
|
||||
// of sources.
|
||||
if len(svc.Meta) > 0 && svc.Meta[metaExternalSource] != "" {
|
||||
source := svc.Meta[metaExternalSource]
|
||||
if len(svc.Meta) > 0 && svc.Meta[structs.MetaExternalSource] != "" {
|
||||
source := svc.Meta[structs.MetaExternalSource]
|
||||
if sum.externalSourceSet == nil {
|
||||
sum.externalSourceSet = make(map[string]struct{})
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package agent
|
|||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/hashicorp/consul/sdk/testutil/retry"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
@ -246,7 +247,7 @@ func TestUiServices(t *testing.T) {
|
|||
Service: "api-proxy",
|
||||
ID: "api-proxy-1",
|
||||
Tags: []string{},
|
||||
Meta: map[string]string{metaExternalSource: "k8s"},
|
||||
Meta: map[string]string{structs.MetaExternalSource: "k8s"},
|
||||
Port: 1234,
|
||||
Proxy: structs.ConnectProxyConfig{
|
||||
DestinationServiceName: "api",
|
||||
|
@ -272,7 +273,7 @@ func TestUiServices(t *testing.T) {
|
|||
Service: "web",
|
||||
ID: "web-1",
|
||||
Tags: []string{},
|
||||
Meta: map[string]string{metaExternalSource: "k8s"},
|
||||
Meta: map[string]string{structs.MetaExternalSource: "k8s"},
|
||||
Port: 1234,
|
||||
},
|
||||
Checks: []*structs.HealthCheck{
|
||||
|
@ -936,7 +937,7 @@ func TestUIServiceTopology(t *testing.T) {
|
|||
a := NewTestAgent(t, "")
|
||||
defer a.Shutdown()
|
||||
|
||||
// Register terminating gateway and config entry linking it to postgres + redis
|
||||
// Register api -> web -> redis
|
||||
{
|
||||
registrations := map[string]*structs.RegisterRequest{
|
||||
"Node foo": {
|
||||
|
@ -1204,115 +1205,201 @@ func TestUIServiceTopology(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
t.Run("api", func(t *testing.T) {
|
||||
// Request topology for api
|
||||
req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/api", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
obj, err := a.srv.UIServiceTopology(resp, req)
|
||||
assert.Nil(t, err)
|
||||
assertIndex(t, resp)
|
||||
|
||||
expect := ServiceTopology{
|
||||
Upstreams: []*ServiceSummary{
|
||||
{
|
||||
Name: "web",
|
||||
Datacenter: "dc1",
|
||||
Nodes: []string{"bar", "baz"},
|
||||
InstanceCount: 2,
|
||||
ChecksPassing: 3,
|
||||
ChecksWarning: 1,
|
||||
ChecksCritical: 2,
|
||||
EnterpriseMeta: *structs.DefaultEnterpriseMeta(),
|
||||
// Add intentions: deny all, web -> redis with L7 perms, but omit intention for api -> web
|
||||
{
|
||||
entries := []structs.ConfigEntryRequest{
|
||||
{
|
||||
Datacenter: "dc1",
|
||||
Entry: &structs.ProxyConfigEntry{
|
||||
Kind: structs.ProxyDefaults,
|
||||
Name: structs.ProxyConfigGlobal,
|
||||
Config: map[string]interface{}{
|
||||
"protocol": "http",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Datacenter: "dc1",
|
||||
Entry: &structs.ServiceIntentionsConfigEntry{
|
||||
Kind: structs.ServiceIntentions,
|
||||
Name: "redis",
|
||||
Sources: []*structs.SourceIntention{
|
||||
{
|
||||
Name: "web",
|
||||
Permissions: []*structs.IntentionPermission{
|
||||
{
|
||||
Action: structs.IntentionActionAllow,
|
||||
HTTP: &structs.IntentionHTTPPermission{
|
||||
Methods: []string{"GET"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Datacenter: "dc1",
|
||||
Entry: &structs.ServiceIntentionsConfigEntry{
|
||||
Kind: structs.ServiceIntentions,
|
||||
Name: "*",
|
||||
Meta: map[string]string{structs.MetaExternalSource: "nomad"},
|
||||
Sources: []*structs.SourceIntention{
|
||||
{
|
||||
Name: "*",
|
||||
Action: structs.IntentionActionDeny,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
FilteredByACLs: false,
|
||||
}
|
||||
result := obj.(ServiceTopology)
|
||||
for _, req := range entries {
|
||||
out := false
|
||||
require.NoError(t, a.RPC("ConfigEntry.Apply", &req, &out))
|
||||
}
|
||||
}
|
||||
|
||||
// Internal accounting that is not returned in JSON response
|
||||
for _, u := range result.Upstreams {
|
||||
u.externalSourceSet = nil
|
||||
u.checks = nil
|
||||
}
|
||||
require.Equal(t, expect, result)
|
||||
t.Run("api", func(t *testing.T) {
|
||||
retry.Run(t, func(r *retry.R) {
|
||||
// Request topology for api
|
||||
req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/api", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
obj, err := a.srv.UIServiceTopology(resp, req)
|
||||
assert.Nil(r, err)
|
||||
require.NoError(r, checkIndex(resp))
|
||||
|
||||
expect := ServiceTopology{
|
||||
Upstreams: []*ServiceTopologySummary{
|
||||
{
|
||||
ServiceSummary: ServiceSummary{
|
||||
Name: "web",
|
||||
Datacenter: "dc1",
|
||||
Nodes: []string{"bar", "baz"},
|
||||
InstanceCount: 2,
|
||||
ChecksPassing: 3,
|
||||
ChecksWarning: 1,
|
||||
ChecksCritical: 2,
|
||||
EnterpriseMeta: *structs.DefaultEnterpriseMeta(),
|
||||
},
|
||||
Intention: structs.IntentionDecisionSummary{
|
||||
Allowed: false,
|
||||
HasPermissions: false,
|
||||
ExternalSource: "nomad",
|
||||
},
|
||||
},
|
||||
},
|
||||
FilteredByACLs: false,
|
||||
}
|
||||
result := obj.(ServiceTopology)
|
||||
|
||||
// Internal accounting that is not returned in JSON response
|
||||
for _, u := range result.Upstreams {
|
||||
u.externalSourceSet = nil
|
||||
u.checks = nil
|
||||
}
|
||||
require.Equal(r, expect, result)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("web", func(t *testing.T) {
|
||||
// Request topology for web
|
||||
req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/web", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
obj, err := a.srv.UIServiceTopology(resp, req)
|
||||
assert.Nil(t, err)
|
||||
assertIndex(t, resp)
|
||||
retry.Run(t, func(r *retry.R) {
|
||||
// Request topology for web
|
||||
req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/web", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
obj, err := a.srv.UIServiceTopology(resp, req)
|
||||
assert.Nil(r, err)
|
||||
require.NoError(r, checkIndex(resp))
|
||||
|
||||
expect := ServiceTopology{
|
||||
Upstreams: []*ServiceSummary{
|
||||
{
|
||||
Name: "redis",
|
||||
Datacenter: "dc1",
|
||||
Nodes: []string{"zip"},
|
||||
InstanceCount: 1,
|
||||
ChecksPassing: 2,
|
||||
ChecksCritical: 1,
|
||||
EnterpriseMeta: *structs.DefaultEnterpriseMeta(),
|
||||
expect := ServiceTopology{
|
||||
Upstreams: []*ServiceTopologySummary{
|
||||
{
|
||||
ServiceSummary: ServiceSummary{
|
||||
Name: "redis",
|
||||
Datacenter: "dc1",
|
||||
Nodes: []string{"zip"},
|
||||
InstanceCount: 1,
|
||||
ChecksPassing: 2,
|
||||
ChecksCritical: 1,
|
||||
EnterpriseMeta: *structs.DefaultEnterpriseMeta(),
|
||||
},
|
||||
Intention: structs.IntentionDecisionSummary{
|
||||
Allowed: false,
|
||||
HasPermissions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Downstreams: []*ServiceSummary{
|
||||
{
|
||||
Name: "api",
|
||||
Datacenter: "dc1",
|
||||
Nodes: []string{"foo"},
|
||||
InstanceCount: 1,
|
||||
ChecksPassing: 3,
|
||||
EnterpriseMeta: *structs.DefaultEnterpriseMeta(),
|
||||
Downstreams: []*ServiceTopologySummary{
|
||||
{
|
||||
ServiceSummary: ServiceSummary{
|
||||
Name: "api",
|
||||
Datacenter: "dc1",
|
||||
Nodes: []string{"foo"},
|
||||
InstanceCount: 1,
|
||||
ChecksPassing: 3,
|
||||
EnterpriseMeta: *structs.DefaultEnterpriseMeta(),
|
||||
},
|
||||
Intention: structs.IntentionDecisionSummary{
|
||||
Allowed: false,
|
||||
HasPermissions: false,
|
||||
ExternalSource: "nomad",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
FilteredByACLs: false,
|
||||
}
|
||||
result := obj.(ServiceTopology)
|
||||
FilteredByACLs: false,
|
||||
}
|
||||
result := obj.(ServiceTopology)
|
||||
|
||||
// Internal accounting that is not returned in JSON response
|
||||
for _, u := range result.Upstreams {
|
||||
u.externalSourceSet = nil
|
||||
u.checks = nil
|
||||
}
|
||||
for _, d := range result.Downstreams {
|
||||
d.externalSourceSet = nil
|
||||
d.checks = nil
|
||||
}
|
||||
require.Equal(t, expect, result)
|
||||
// Internal accounting that is not returned in JSON response
|
||||
for _, u := range result.Upstreams {
|
||||
u.externalSourceSet = nil
|
||||
u.checks = nil
|
||||
}
|
||||
for _, d := range result.Downstreams {
|
||||
d.externalSourceSet = nil
|
||||
d.checks = nil
|
||||
}
|
||||
require.Equal(r, expect, result)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("redis", func(t *testing.T) {
|
||||
// Request topology for redis
|
||||
req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/redis", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
obj, err := a.srv.UIServiceTopology(resp, req)
|
||||
assert.Nil(t, err)
|
||||
assertIndex(t, resp)
|
||||
retry.Run(t, func(r *retry.R) {
|
||||
// Request topology for redis
|
||||
req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/redis", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
obj, err := a.srv.UIServiceTopology(resp, req)
|
||||
assert.Nil(r, err)
|
||||
require.NoError(r, checkIndex(resp))
|
||||
|
||||
expect := ServiceTopology{
|
||||
Downstreams: []*ServiceSummary{
|
||||
{
|
||||
Name: "web",
|
||||
Datacenter: "dc1",
|
||||
Nodes: []string{"bar", "baz"},
|
||||
InstanceCount: 2,
|
||||
ChecksPassing: 3,
|
||||
ChecksWarning: 1,
|
||||
ChecksCritical: 2,
|
||||
EnterpriseMeta: *structs.DefaultEnterpriseMeta(),
|
||||
expect := ServiceTopology{
|
||||
Downstreams: []*ServiceTopologySummary{
|
||||
{
|
||||
ServiceSummary: ServiceSummary{
|
||||
Name: "web",
|
||||
Datacenter: "dc1",
|
||||
Nodes: []string{"bar", "baz"},
|
||||
InstanceCount: 2,
|
||||
ChecksPassing: 3,
|
||||
ChecksWarning: 1,
|
||||
ChecksCritical: 2,
|
||||
EnterpriseMeta: *structs.DefaultEnterpriseMeta(),
|
||||
},
|
||||
Intention: structs.IntentionDecisionSummary{
|
||||
Allowed: false,
|
||||
HasPermissions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
FilteredByACLs: false,
|
||||
}
|
||||
result := obj.(ServiceTopology)
|
||||
FilteredByACLs: false,
|
||||
}
|
||||
result := obj.(ServiceTopology)
|
||||
|
||||
// Internal accounting that is not returned in JSON response
|
||||
for _, d := range result.Downstreams {
|
||||
d.externalSourceSet = nil
|
||||
d.checks = nil
|
||||
}
|
||||
require.Equal(t, expect, result)
|
||||
// Internal accounting that is not returned in JSON response
|
||||
for _, d := range result.Downstreams {
|
||||
d.externalSourceSet = nil
|
||||
d.checks = nil
|
||||
}
|
||||
require.Equal(r, expect, result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue