diff --git a/agent/agent_endpoint.go b/agent/agent_endpoint.go index 1edf925a2..73e0f5364 100644 --- a/agent/agent_endpoint.go +++ b/agent/agent_endpoint.go @@ -1341,6 +1341,8 @@ func (s *HTTPHandlers) AgentConnectCALeafCert(resp http.ResponseWriter, req *htt // // POST /v1/agent/connect/authorize // +// NOTE: This endpoint treats any L7 intentions as DENY. +// // Note: when this logic changes, consider if the Intention.Check RPC method // also needs to be updated. func (s *HTTPHandlers) AgentConnectAuthorize(resp http.ResponseWriter, req *http.Request) (interface{}, error) { diff --git a/agent/connect_auth.go b/agent/connect_auth.go index da3f05fa7..b3084e38b 100644 --- a/agent/connect_auth.go +++ b/agent/connect_auth.go @@ -11,10 +11,15 @@ import ( "github.com/hashicorp/consul/agent/structs" ) +// TODO(rb/intentions): this should move back into the agent endpoint since +// there is no ext_authz implementation anymore. +// // ConnectAuthorize implements the core authorization logic for Connect. It's in // a separate agent method here because we need to re-use this both in our own // HTTP API authz endpoint and in the gRPX xDS/ext_authz API for envoy. // +// NOTE: This treats any L7 intentions as DENY. +// // The ACL token and the auth request are provided and the auth decision (true // means authorized) and reason string are returned. // @@ -97,12 +102,26 @@ func (a *Agent) ConnectAuthorize(token string, return returnErr(fmt.Errorf("Internal error loading matches")) } - // Test the authorization for each match + // Figure out which source matches this request. + var ixnMatch *structs.Intention for _, ixn := range reply.Matches[0] { - if auth, ok := uriService.Authorize(ixn); ok { - reason = fmt.Sprintf("Matched intention: %s", ixn.String()) + if _, ok := uriService.Authorize(ixn); ok { + ixnMatch = ixn + break + } + } + + if ixnMatch != nil { + if len(ixnMatch.Permissions) == 0 { + // This is an L4 intention. + reason = fmt.Sprintf("Matched L4 intention: %s", ixnMatch.String()) + auth := ixnMatch.Action == structs.IntentionActionAllow return auth, reason, &meta, nil } + + // This is an L7 intention, so DENY. + reason = fmt.Sprintf("Matched L7 intention: %s", ixnMatch.String()) + return false, reason, &meta, nil } // No match, we need to determine the default behavior. We do this by diff --git a/agent/consul/intention_endpoint.go b/agent/consul/intention_endpoint.go index 7a7682868..29b13be29 100644 --- a/agent/consul/intention_endpoint.go +++ b/agent/consul/intention_endpoint.go @@ -288,10 +288,10 @@ func (s *Intention) Apply( } if prevEntry == nil { - upsertEntry = args.Intention.ToConfigEntry() + upsertEntry = args.Intention.ToConfigEntry(true) } else { upsertEntry = prevEntry.Clone() - upsertEntry.Sources = append(upsertEntry.Sources, args.Intention.ToSourceIntention()) + upsertEntry.Sources = append(upsertEntry.Sources, args.Intention.ToSourceIntention(true)) } case structs.IntentionOpUpdate: @@ -306,7 +306,7 @@ func (s *Intention) Apply( upsertEntry = prevEntry.Clone() for i, src := range upsertEntry.Sources { if src.LegacyID == args.Intention.ID { - upsertEntry.Sources[i] = args.Intention.ToSourceIntention() + upsertEntry.Sources[i] = args.Intention.ToSourceIntention(true) break } } @@ -339,7 +339,7 @@ func (s *Intention) Apply( return fmt.Errorf("Meta must not be specified") } - upsertEntry = args.Intention.ToConfigEntry() + upsertEntry = args.Intention.ToConfigEntry(false) } else { upsertEntry = prevEntry.Clone() @@ -363,13 +363,13 @@ func (s *Intention) Apply( found := false for i, src := range upsertEntry.Sources { if src.SourceServiceName() == sn { - upsertEntry.Sources[i] = args.Intention.ToSourceIntention() + upsertEntry.Sources[i] = args.Intention.ToSourceIntention(false) found = true break } } if !found { - upsertEntry.Sources = append(upsertEntry.Sources, args.Intention.ToSourceIntention()) + upsertEntry.Sources = append(upsertEntry.Sources, args.Intention.ToSourceIntention(false)) } } @@ -712,6 +712,8 @@ func (s *Intention) Match( // Check tests a source/destination and returns whether it would be allowed // or denied based on the current ACL configuration. // +// NOTE: This endpoint treats any L7 intentions as DENY. +// // Note: Whenever the logic for this method is changed, you should take // a look at the agent authorize endpoint (agent/agent_endpoint.go) since // the logic there is similar. @@ -802,14 +804,30 @@ func (s *Intention) Check( return errors.New("internal error loading matches") } - // Check the authorization for each match + // Figure out which source matches this request. + var ixnMatch *structs.Intention for _, ixn := range matches[0] { - if auth, ok := uri.Authorize(ixn); ok { - reply.Allowed = auth - return nil + 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. // The default behavior if ACLs are disabled is to allow connections diff --git a/agent/consul/intention_endpoint_test.go b/agent/consul/intention_endpoint_test.go index d8a22c5ab..e6b3544cb 100644 --- a/agent/consul/intention_endpoint_test.go +++ b/agent/consul/intention_endpoint_test.go @@ -343,6 +343,22 @@ func TestIntentionApply_WithoutIDs(t *testing.T) { defaultEntMeta := structs.DefaultEnterpriseMeta() + // Force "test" to be L7-capable. + { + args := structs.ConfigEntryRequest{ + Datacenter: "dc1", + Entry: &structs.ServiceConfigEntry{ + Kind: structs.ServiceDefaults, + Name: "test", + Protocol: "http", + }, + } + + var out bool + require.NoError(t, msgpackrpc.CallWithCodec(codec, "ConfigEntry.Apply", &args, &out)) + require.True(t, out) + } + opApply := func(req *structs.IntentionRequest) error { req.Datacenter = "dc1" var ignored string @@ -419,6 +435,10 @@ func TestIntentionApply_WithoutIDs(t *testing.T) { got := resp.Intentions[0] require.Equal(t, "original", got.Description) + // L4 + require.Equal(t, structs.IntentionActionAllow, got.Action) + require.Empty(t, got.Permissions) + // Verify it is in the new-style. require.Empty(t, got.ID) require.True(t, got.CreatedAt.IsZero()) @@ -483,6 +503,10 @@ func TestIntentionApply_WithoutIDs(t *testing.T) { got := resp.Intentions[0] require.Equal(t, "updated", got.Description) + // L4 + require.Equal(t, structs.IntentionActionAllow, got.Action) + require.Empty(t, got.Permissions) + // Verify it is in the new-style. require.Empty(t, got.ID) require.True(t, got.CreatedAt.IsZero()) @@ -502,8 +526,15 @@ func TestIntentionApply_WithoutIDs(t *testing.T) { Intention: &structs.Intention{ SourceName: "assay", DestinationName: "test", - Action: structs.IntentionActionDeny, Description: "original-2", + Permissions: []*structs.IntentionPermission{ + { + Action: structs.IntentionActionAllow, + HTTP: &structs.IntentionHTTPPermission{ + PathExact: "/foo", + }, + }, + }, }, })) @@ -521,6 +552,17 @@ func TestIntentionApply_WithoutIDs(t *testing.T) { got := resp.Intentions[0] require.Equal(t, "original-2", got.Description) + // L7 + require.Empty(t, got.Action) + require.Equal(t, []*structs.IntentionPermission{ + { + Action: structs.IntentionActionAllow, + HTTP: &structs.IntentionHTTPPermission{ + PathExact: "/foo", + }, + }, + }, got.Permissions) + // Verify it is in the new-style. require.Empty(t, got.ID) require.True(t, got.CreatedAt.IsZero()) @@ -556,10 +598,17 @@ func TestIntentionApply_WithoutIDs(t *testing.T) { { Name: "assay", EnterpriseMeta: *defaultEntMeta, - Action: structs.IntentionActionDeny, Description: "original-2", Precedence: 9, Type: structs.IntentionSourceConsul, + Permissions: []*structs.IntentionPermission{ + { + Action: structs.IntentionActionAllow, + HTTP: &structs.IntentionHTTPPermission{ + PathExact: "/foo", + }, + }, + }, }, }, RaftIndex: entry.RaftIndex, @@ -609,10 +658,17 @@ func TestIntentionApply_WithoutIDs(t *testing.T) { { Name: "assay", EnterpriseMeta: *defaultEntMeta, - Action: structs.IntentionActionDeny, Description: "original-2", Precedence: 9, Type: structs.IntentionSourceConsul, + Permissions: []*structs.IntentionPermission{ + { + Action: structs.IntentionActionAllow, + HTTP: &structs.IntentionHTTPPermission{ + PathExact: "/foo", + }, + }, + }, }, }, RaftIndex: entry.RaftIndex, diff --git a/agent/consul/leader_intentions.go b/agent/consul/leader_intentions.go index 13c799730..ece38fdad 100644 --- a/agent/consul/leader_intentions.go +++ b/agent/consul/leader_intentions.go @@ -383,6 +383,16 @@ func (s *Server) replicateLegacyIntentionsOnce(ctx context.Context, lastFetchInd return 0, false, err } + // Do a quick sanity check that somehow Permissions didn't slip through. + // This shouldn't be necessary, but one extra check isn't going to hurt + // anything. + for _, ixn := range local { + if len(ixn.Permissions) > 0 { + // Assume that the data origin has switched to config entries. + return 0, true, nil + } + } + // Compute the diff between the remote and local intentions. deletes, updates := diffIntentions(local, remote.Intentions) txnOpSets := batchLegacyIntentionUpdates(deletes, updates) diff --git a/agent/consul/state/config_entry.go b/agent/consul/state/config_entry.go index 7ac15d4a9..9779aa6aa 100644 --- a/agent/consul/state/config_entry.go +++ b/agent/consul/state/config_entry.go @@ -324,7 +324,7 @@ func insertConfigEntryWithTxn(tx *txn, idx uint64, conf structs.ConfigEntry) err func validateProposedConfigEntryInGraph( tx ReadTxn, kind, name string, - next structs.ConfigEntry, + proposedEntry structs.ConfigEntry, entMeta *structs.EnterpriseMeta, ) error { validateAllChains := false @@ -350,14 +350,11 @@ func validateProposedConfigEntryInGraph( return err } case structs.ServiceIntentions: - // TODO(rb): should this validate protocols? - return nil - default: return fmt.Errorf("unhandled kind %q during validation of %q", kind, name) } - return validateProposedConfigEntryInServiceGraph(tx, kind, name, next, validateAllChains, entMeta) + return validateProposedConfigEntryInServiceGraph(tx, kind, name, proposedEntry, validateAllChains, entMeta) } func checkGatewayClash( @@ -475,7 +472,7 @@ func (s *Store) discoveryChainSourcesTxn(tx ReadTxn, ws memdb.WatchSet, dc strin func validateProposedConfigEntryInServiceGraph( tx ReadTxn, kind, name string, - next structs.ConfigEntry, + proposedEntry structs.ConfigEntry, validateAllChains bool, entMeta *structs.EnterpriseMeta, ) error { @@ -484,6 +481,7 @@ func validateProposedConfigEntryInServiceGraph( var ( checkChains = make(map[structs.ServiceID]struct{}) checkIngress []*structs.IngressGatewayConfigEntry + checkIntentions []*structs.ServiceIntentionsConfigEntry enforceIngressProtocolsMatch bool ) @@ -503,11 +501,11 @@ func validateProposedConfigEntryInServiceGraph( } } - _, entries, err := configEntriesByKindTxn(tx, nil, structs.IngressGateway, structs.WildcardEnterpriseMeta()) + _, ingressEntries, err := configEntriesByKindTxn(tx, nil, structs.IngressGateway, structs.WildcardEnterpriseMeta()) if err != nil { return err } - for _, entry := range entries { + for _, entry := range ingressEntries { ingress, ok := entry.(*structs.IngressGatewayConfigEntry) if !ok { return fmt.Errorf("type %T is not an ingress gateway config entry", entry) @@ -515,17 +513,43 @@ func validateProposedConfigEntryInServiceGraph( checkIngress = append(checkIngress, ingress) } + _, ixnEntries, err := configEntriesByKindTxn(tx, nil, structs.ServiceIntentions, structs.WildcardEnterpriseMeta()) + if err != nil { + return err + } + for _, entry := range ixnEntries { + ixn, ok := entry.(*structs.ServiceIntentionsConfigEntry) + if !ok { + return fmt.Errorf("type %T is not a service intentions config entry", entry) + } + checkIntentions = append(checkIntentions, ixn) + } + + } else if kind == structs.ServiceIntentions { + // Check that the protocols match. + + // This is the case for deleting a config entry + if proposedEntry == nil { + return nil + } + + ixn, ok := proposedEntry.(*structs.ServiceIntentionsConfigEntry) + if !ok { + return fmt.Errorf("type %T is not a service intentions config entry", proposedEntry) + } + checkIntentions = append(checkIntentions, ixn) + } else if kind == structs.IngressGateway { // Checking an ingress pointing to multiple chains. // This is the case for deleting a config entry - if next == nil { + if proposedEntry == nil { return nil } - ingress, ok := next.(*structs.IngressGatewayConfigEntry) + ingress, ok := proposedEntry.(*structs.IngressGatewayConfigEntry) if !ok { - return fmt.Errorf("type %T is not an ingress gateway config entry", next) + return fmt.Errorf("type %T is not an ingress gateway config entry", proposedEntry) } checkIngress = append(checkIngress, ingress) @@ -536,6 +560,28 @@ func validateProposedConfigEntryInServiceGraph( } else { // Must be a single chain. + // Check to see if we should ensure L7 intentions have an L7 protocol. + _, ixn, err := getServiceIntentionsConfigEntryTxn( + tx, nil, name, nil, entMeta, + ) + if err != nil { + return err + } else if ixn != nil { + checkIntentions = append(checkIntentions, ixn) + } + + _, ixnEntries, err := configEntriesByKindTxn(tx, nil, structs.ServiceIntentions, structs.WildcardEnterpriseMeta()) + if err != nil { + return err + } + for _, entry := range ixnEntries { + ixn, ok := entry.(*structs.ServiceIntentionsConfigEntry) + if !ok { + return fmt.Errorf("type %T is not a service intentions config entry", entry) + } + checkIntentions = append(checkIntentions, ixn) + } + sid := structs.NewServiceID(name, entMeta) checkChains[sid] = struct{}{} @@ -559,16 +605,20 @@ func validateProposedConfigEntryInServiceGraph( } } - // Ensure if any ingress is affected that we fetch all of the chains needed - // to fully validate that ingress. + // Ensure if any ingress or intention is affected that we fetch all of the + // chains needed to fully validate them. for _, ingress := range checkIngress { for _, svcID := range ingress.ListRelatedServices() { checkChains[svcID] = struct{}{} } } + for _, ixn := range checkIntentions { + sn := ixn.DestinationServiceName() + checkChains[sn.ToServiceID()] = struct{}{} + } overrides := map[structs.ConfigEntryKindName]structs.ConfigEntry{ - structs.NewConfigEntryKindName(kind, name, entMeta): next, + structs.NewConfigEntryKindName(kind, name, entMeta): proposedEntry, } var ( @@ -619,6 +669,25 @@ func validateProposedConfigEntryInServiceGraph( } } + // Now validate that intentions with L7 permissions reference HTTP services + for _, e := range checkIntentions { + // We only have to double check things that try to use permissions + if e.HasWildcardDestination() || !e.HasAnyPermissions() { + continue + } + sn := e.DestinationServiceName() + svcID := sn.ToServiceID() + + svcProto := svcProtocols[svcID] + if !structs.IsProtocolHTTPLike(svcProto) { + return fmt.Errorf( + "service %q has protocol %q, which is incompatible with L7 intentions permissions", + svcID.String(), + svcProto, + ) + } + } + return nil } diff --git a/agent/consul/state/config_entry_test.go b/agent/consul/state/config_entry_test.go index 7b233272c..30f8dd00f 100644 --- a/agent/consul/state/config_entry_test.go +++ b/agent/consul/state/config_entry_test.go @@ -2038,3 +2038,235 @@ func TestTargetsForSource(t *testing.T) { }) } } + +func TestStore_ValidateServiceIntentionsErrorOnIncompatibleProtocols(t *testing.T) { + l7perms := []*structs.IntentionPermission{ + { + Action: structs.IntentionActionAllow, + HTTP: &structs.IntentionHTTPPermission{ + PathPrefix: "/v2/", + }, + }, + } + + serviceDefaults := func(service, protocol string) *structs.ServiceConfigEntry { + return &structs.ServiceConfigEntry{ + Kind: structs.ServiceDefaults, + Name: service, + Protocol: protocol, + } + } + + proxyDefaults := func(protocol string) *structs.ProxyConfigEntry { + return &structs.ProxyConfigEntry{ + Kind: structs.ProxyDefaults, + Name: structs.ProxyConfigGlobal, + Config: map[string]interface{}{ + "protocol": protocol, + }, + } + } + + type operation struct { + entry structs.ConfigEntry + deletion bool + } + + type testcase struct { + ops []operation + expectLastErr string + } + + cases := map[string]testcase{ + "L4 intention cannot upgrade to L7 when tcp": { + ops: []operation{ + { // set the target service as tcp + entry: serviceDefaults("api", "tcp"), + }, + { // create an L4 intention + entry: &structs.ServiceIntentionsConfigEntry{ + Kind: structs.ServiceIntentions, + Name: "api", + Sources: []*structs.SourceIntention{ + {Name: "web", Action: structs.IntentionActionAllow}, + }, + }, + }, + { // Should fail if converted to L7 + entry: &structs.ServiceIntentionsConfigEntry{ + Kind: structs.ServiceIntentions, + Name: "api", + Sources: []*structs.SourceIntention{ + {Name: "web", Permissions: l7perms}, + }, + }, + }, + }, + expectLastErr: `has protocol "tcp"`, + }, + "L4 intention can upgrade to L7 when made http via service-defaults": { + ops: []operation{ + { // set the target service as tcp + entry: serviceDefaults("api", "tcp"), + }, + { // create an L4 intention + entry: &structs.ServiceIntentionsConfigEntry{ + Kind: structs.ServiceIntentions, + Name: "api", + Sources: []*structs.SourceIntention{ + {Name: "web", Action: structs.IntentionActionAllow}, + }, + }, + }, + { // set the target service as http + entry: serviceDefaults("api", "http"), + }, + { // Should succeed if converted to L7 + entry: &structs.ServiceIntentionsConfigEntry{ + Kind: structs.ServiceIntentions, + Name: "api", + Sources: []*structs.SourceIntention{ + {Name: "web", Permissions: l7perms}, + }, + }, + }, + }, + }, + "L4 intention can upgrade to L7 when made http via proxy-defaults": { + ops: []operation{ + { // set the target service as tcp + entry: proxyDefaults("tcp"), + }, + { // create an L4 intention + entry: &structs.ServiceIntentionsConfigEntry{ + Kind: structs.ServiceIntentions, + Name: "api", + Sources: []*structs.SourceIntention{ + {Name: "web", Action: structs.IntentionActionAllow}, + }, + }, + }, + { // set the target service as http + entry: proxyDefaults("http"), + }, + { // Should succeed if converted to L7 + entry: &structs.ServiceIntentionsConfigEntry{ + Kind: structs.ServiceIntentions, + Name: "api", + Sources: []*structs.SourceIntention{ + {Name: "web", Permissions: l7perms}, + }, + }, + }, + }, + }, + "L7 intention cannot have protocol downgraded to tcp via modification via service-defaults": { + ops: []operation{ + { // set the target service as http + entry: serviceDefaults("api", "http"), + }, + { // create an L7 intention + entry: &structs.ServiceIntentionsConfigEntry{ + Kind: structs.ServiceIntentions, + Name: "api", + Sources: []*structs.SourceIntention{ + {Name: "web", Permissions: l7perms}, + }, + }, + }, + { // setting the target service as tcp should fail + entry: serviceDefaults("api", "tcp"), + }, + }, + expectLastErr: `has protocol "tcp"`, + }, + "L7 intention cannot have protocol downgraded to tcp via modification via proxy-defaults": { + ops: []operation{ + { // set the target service as http + entry: proxyDefaults("http"), + }, + { // create an L7 intention + entry: &structs.ServiceIntentionsConfigEntry{ + Kind: structs.ServiceIntentions, + Name: "api", + Sources: []*structs.SourceIntention{ + {Name: "web", Permissions: l7perms}, + }, + }, + }, + { // setting the target service as tcp should fail + entry: proxyDefaults("tcp"), + }, + }, + expectLastErr: `has protocol "tcp"`, + }, + "L7 intention cannot have protocol downgraded to tcp via deletion of service-defaults": { + ops: []operation{ + { // set the target service as http + entry: serviceDefaults("api", "http"), + }, + { // create an L7 intention + entry: &structs.ServiceIntentionsConfigEntry{ + Kind: structs.ServiceIntentions, + Name: "api", + Sources: []*structs.SourceIntention{ + {Name: "web", Permissions: l7perms}, + }, + }, + }, + { // setting the target service as tcp should fail + entry: serviceDefaults("api", "tcp"), + deletion: true, + }, + }, + expectLastErr: `has protocol "tcp"`, + }, + "L7 intention cannot have protocol downgraded to tcp via deletion of proxy-defaults": { + ops: []operation{ + { // set the target service as http + entry: proxyDefaults("http"), + }, + { // create an L7 intention + entry: &structs.ServiceIntentionsConfigEntry{ + Kind: structs.ServiceIntentions, + Name: "api", + Sources: []*structs.SourceIntention{ + {Name: "web", Permissions: l7perms}, + }, + }, + }, + { // setting the target service as tcp should fail + entry: proxyDefaults("tcp"), + deletion: true, + }, + }, + expectLastErr: `has protocol "tcp"`, + }, + } + + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + s := testStateStore(t) + + var nextIndex = uint64(1) + + for i, op := range tc.ops { + isLast := (i == len(tc.ops)-1) + + var err error + if op.deletion { + err = s.DeleteConfigEntry(nextIndex, op.entry.GetKind(), op.entry.GetName(), nil) + } else { + err = s.EnsureConfigEntry(nextIndex, op.entry, nil) + } + + if isLast && tc.expectLastErr != "" { + testutil.RequireErrorContains(t, err, `has protocol "tcp"`) + } else { + require.NoError(t, err) + } + } + }) + } +} diff --git a/agent/structs/config_entry_discoverychain.go b/agent/structs/config_entry_discoverychain.go index 9c0a50919..b9cee7277 100644 --- a/agent/structs/config_entry_discoverychain.go +++ b/agent/structs/config_entry_discoverychain.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "math" + "net/http" "regexp" "sort" "strconv" @@ -108,15 +109,12 @@ func (e *ServiceRouterConfigEntry) Normalize() error { } httpMatch := route.Match.HTTP - if len(httpMatch.Methods) == 0 { - continue - } - for j := 0; j < len(httpMatch.Methods); j++ { httpMatch.Methods[j] = strings.ToUpper(httpMatch.Methods[j]) } + if route.Destination != nil && route.Destination.Namespace == "" { - route.Destination.Namespace = e.EnterpriseMeta.NamespaceOrDefault() + route.Destination.Namespace = e.EnterpriseMeta.NamespaceOrEmpty() } } @@ -209,6 +207,9 @@ func (e *ServiceRouterConfigEntry) Validate() error { if len(route.Match.HTTP.Methods) > 0 { found := make(map[string]struct{}) for _, m := range route.Match.HTTP.Methods { + if !isValidHTTPMethod(m) { + return fmt.Errorf("Route[%d] Methods contains an invalid method %q", i, m) + } if _, ok := found[m]; ok { return fmt.Errorf("Route[%d] Methods contains %q more than once", i, m) } @@ -227,6 +228,23 @@ func (e *ServiceRouterConfigEntry) Validate() error { return nil } +func isValidHTTPMethod(method string) bool { + switch method { + case http.MethodGet, + http.MethodHead, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodConnect, + http.MethodOptions, + http.MethodTrace: + return true + default: + return false + } +} + func (e *ServiceRouterConfigEntry) CanRead(rule acl.Authorizer) bool { return canReadDiscoveryChain(e, rule) } diff --git a/agent/structs/config_entry_intentions.go b/agent/structs/config_entry_intentions.go index 39980f2f6..d3e0e072f 100644 --- a/agent/structs/config_entry_intentions.go +++ b/agent/structs/config_entry_intentions.go @@ -72,6 +72,7 @@ func (e *ServiceIntentionsConfigEntry) ToIntention(src *SourceIntention) *Intent SourceName: src.Name, SourceType: src.Type, Action: src.Action, + Permissions: src.Permissions, Meta: meta, Precedence: src.Precedence, DestinationNS: e.NamespaceOrDefault(), @@ -135,7 +136,27 @@ type SourceIntention struct { // Action is whether this is an allowlist or denylist intention. // // formerly Intention.Action - Action IntentionAction + // + // NOTE: this is mutually exclusive with the Permissions field. + Action IntentionAction `json:",omitempty"` + + // Permissions is the list of additional L7 attributes that extend the + // intention definition. + // + // Permissions are interpreted in the order represented in the slice. In + // default-deny mode, deny permissions are logically subtracted from all + // following allow permissions. Multiple allow permissions are then ORed + // together. + // + // For example: + // ["deny /v2/admin", "allow /v2/*", "allow GET /healthz"] + // + // Is logically interpreted as: + // allow: [ + // "(/v2/*) AND NOT (/v2/admin)", + // "(GET /healthz) AND NOT (/v2/admin)" + // ] + Permissions []*IntentionPermission `json:",omitempty"` // Precedence is the order that the intention will be applied, with // larger numbers being applied first. This is a read-only field, on @@ -182,6 +203,65 @@ type SourceIntention struct { EnterpriseMeta `hcl:",squash" mapstructure:",squash"` } +type IntentionPermission struct { + Action IntentionAction // required: allow|deny + + HTTP *IntentionHTTPPermission `json:",omitempty"` + + // If we have non-http match criteria for other protocols + // in the future (gRPC, redis, etc) they can go here. + + // Support for edge-decoded JWTs would likely be configured + // in a new top level section here. + + // If we ever add Sentinel support, this is one place we may + // wish to add it. +} + +func (p *IntentionPermission) Clone() *IntentionPermission { + p2 := *p + if p.HTTP != nil { + p2.HTTP = p.HTTP.Clone() + } + return &p2 +} + +type IntentionHTTPPermission struct { + // PathExact, PathPrefix, and PathRegex are mutually exclusive. + PathExact string `json:",omitempty" alias:"path_exact"` + PathPrefix string `json:",omitempty" alias:"path_prefix"` + PathRegex string `json:",omitempty" alias:"path_regex"` + + Header []IntentionHTTPHeaderPermission `json:",omitempty"` + + Methods []string `json:",omitempty"` +} + +func (p *IntentionHTTPPermission) Clone() *IntentionHTTPPermission { + p2 := *p + + if len(p.Header) > 0 { + p2.Header = make([]IntentionHTTPHeaderPermission, 0, len(p.Header)) + for _, hdr := range p.Header { + p2.Header = append(p2.Header, hdr) + } + } + + p2.Methods = cloneStringSlice(p.Methods) + + return &p2 +} + +type IntentionHTTPHeaderPermission struct { + Name string + Present bool `json:",omitempty"` + Exact string `json:",omitempty"` + Prefix string `json:",omitempty"` + Suffix string `json:",omitempty"` + Regex string `json:",omitempty"` + Invert bool `json:",omitempty"` +} + func cloneStringStringMap(m map[string]string) map[string]string { if m == nil { return nil @@ -202,6 +282,13 @@ func (x *SourceIntention) Clone() *SourceIntention { x2.LegacyMeta = cloneStringStringMap(x.LegacyMeta) + if len(x.Permissions) > 0 { + x2.Permissions = make([]*IntentionPermission, 0, len(x.Permissions)) + for _, perm := range x.Permissions { + x2.Permissions = append(x2.Permissions, perm.Clone()) + } + } + return &x2 } @@ -303,6 +390,16 @@ func (e *ServiceIntentionsConfigEntry) normalize(legacyWrite bool) error { src.LegacyCreateTime = nil src.LegacyUpdateTime = nil } + + for _, perm := range src.Permissions { + if perm.HTTP == nil { + continue + } + + for j := 0; j < len(perm.HTTP.Methods); j++ { + perm.HTTP.Methods[j] = strings.ToUpper(perm.HTTP.Methods[j]) + } + } } // The source intentions closer to the head of the list have higher @@ -370,6 +467,20 @@ func (e *ServiceIntentionsConfigEntry) LegacyValidate() error { return e.validate(true) } +func (e *ServiceIntentionsConfigEntry) HasWildcardDestination() bool { + dstNS := e.EnterpriseMeta.NamespaceOrDefault() + return dstNS == WildcardSpecifier || e.Name == WildcardSpecifier +} + +func (e *ServiceIntentionsConfigEntry) HasAnyPermissions() bool { + for _, src := range e.Sources { + if len(src.Permissions) > 0 { + return true + } + } + return false +} + func (e *ServiceIntentionsConfigEntry) validate(legacyWrite bool) error { if e.Name == "" { return fmt.Errorf("Name is required") @@ -379,6 +490,8 @@ func (e *ServiceIntentionsConfigEntry) validate(legacyWrite bool) error { return err } + destIsWild := e.HasWildcardDestination() + if legacyWrite { if len(e.Meta) > 0 { return fmt.Errorf("Meta must be omitted for legacy intention writes") @@ -445,10 +558,20 @@ func (e *ServiceIntentionsConfigEntry) validate(legacyWrite bool) error { } } - switch src.Action { - case IntentionActionAllow, IntentionActionDeny: - default: - return fmt.Errorf("Sources[%d].Action must be set to 'allow' or 'deny'", i) + if legacyWrite || len(src.Permissions) == 0 { + switch src.Action { + case IntentionActionAllow, IntentionActionDeny: + default: + return fmt.Errorf("Sources[%d].Action must be set to 'allow' or 'deny'", i) + } + } + + if len(src.Permissions) > 0 && src.Action != "" { + return fmt.Errorf("Sources[%d].Action must be omitted if Permissions are specified", i) + } + + if destIsWild && len(src.Permissions) > 0 { + return fmt.Errorf("Sources[%d].Permissions cannot be specified on intentions with wildcarded destinations", i) } switch src.Type { @@ -457,6 +580,94 @@ func (e *ServiceIntentionsConfigEntry) validate(legacyWrite bool) error { return fmt.Errorf("Sources[%d].Type must be set to 'consul'", i) } + for j, perm := range src.Permissions { + switch perm.Action { + case IntentionActionAllow, IntentionActionDeny: + default: + return fmt.Errorf("Sources[%d].Permissions[%d].Action must be set to 'allow' or 'deny'", i, j) + } + + errorPrefix := "Sources[%d].Permissions[%d].HTTP" + if perm.HTTP == nil { + return fmt.Errorf(errorPrefix+" is required", i, j) + } + + pathParts := 0 + if perm.HTTP.PathExact != "" { + pathParts++ + if !strings.HasPrefix(perm.HTTP.PathExact, "/") { + return fmt.Errorf( + errorPrefix+".PathExact doesn't start with '/': %q", + i, j, perm.HTTP.PathExact, + ) + } + } + if perm.HTTP.PathPrefix != "" { + pathParts++ + if !strings.HasPrefix(perm.HTTP.PathPrefix, "/") { + return fmt.Errorf( + errorPrefix+".PathPrefix doesn't start with '/': %q", + i, j, perm.HTTP.PathPrefix, + ) + } + } + if perm.HTTP.PathRegex != "" { + pathParts++ + } + if pathParts > 1 { + return fmt.Errorf( + errorPrefix+" should only contain at most one of PathExact, PathPrefix, or PathRegex", + i, j, + ) + } + + permParts := pathParts + + for k, hdr := range perm.HTTP.Header { + if hdr.Name == "" { + return fmt.Errorf(errorPrefix+".Header[%d] missing required Name field", i, j, k) + } + hdrParts := 0 + if hdr.Present { + hdrParts++ + } + if hdr.Exact != "" { + hdrParts++ + } + if hdr.Regex != "" { + hdrParts++ + } + if hdr.Prefix != "" { + hdrParts++ + } + if hdr.Suffix != "" { + hdrParts++ + } + if hdrParts != 1 { + return fmt.Errorf(errorPrefix+".Header[%d] should only contain one of Present, Exact, Prefix, Suffix, or Regex", i, j, k) + } + permParts++ + } + + if len(perm.HTTP.Methods) > 0 { + found := make(map[string]struct{}) + for _, m := range perm.HTTP.Methods { + if !isValidHTTPMethod(m) { + return fmt.Errorf(errorPrefix+".Methods contains an invalid method %q", i, j, m) + } + if _, ok := found[m]; ok { + return fmt.Errorf(errorPrefix+".Methods contains %q more than once", i, j, m) + } + found[m] = struct{}{} + } + permParts++ + } + + if permParts == 0 { + return fmt.Errorf(errorPrefix+" should not be empty", i, j) + } + } + serviceName := src.SourceServiceName() if _, exists := seenSources[serviceName]; exists { return fmt.Errorf("Sources[%d] defines %q more than once", i, serviceName.String()) @@ -521,7 +732,7 @@ func MigrateIntentions(ixns Intentions) []*ServiceIntentionsConfigEntry { } collated := make(map[ServiceName]*ServiceIntentionsConfigEntry) for _, ixn := range ixns { - thisEntry := ixn.ToConfigEntry() + thisEntry := ixn.ToConfigEntry(true) sn := thisEntry.DestinationServiceName() if entry, ok := collated[sn]; ok { diff --git a/agent/structs/config_entry_intentions_test.go b/agent/structs/config_entry_intentions_test.go index 478cb5da0..1d84c12a8 100644 --- a/agent/structs/config_entry_intentions_test.go +++ b/agent/structs/config_entry_intentions_test.go @@ -315,6 +315,688 @@ func TestServiceIntentionsConfigEntry(t *testing.T) { }, validateErr: `Sources[0].Action must be set to 'allow' or 'deny'`, }, + "action must not be set for L7": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Action: IntentionActionAllow, + Description: strings.Repeat("x", 512), + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{PathExact: "/"}, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Action must be omitted if Permissions are specified`, + }, + "permission action must be set": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Description: strings.Repeat("x", 512), + Permissions: []*IntentionPermission{ + { + HTTP: &IntentionHTTPPermission{PathExact: "/"}, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].Action must be set to 'allow' or 'deny'`, + }, + "permission action must allow or deny": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Description: strings.Repeat("x", 512), + Permissions: []*IntentionPermission{ + { + Action: "blah", + HTTP: &IntentionHTTPPermission{PathExact: "/"}, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].Action must be set to 'allow' or 'deny'`, + }, + "permission missing http": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Description: strings.Repeat("x", 512), + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP is required`, + }, + "permission has too many path components (1)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Description: strings.Repeat("x", 512), + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + PathExact: "/", + PathPrefix: "/a", + // PathRegex: "/b", + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP should only contain at most one of PathExact, PathPrefix, or PathRegex`, + }, + "permission has too many path components (2)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Description: strings.Repeat("x", 512), + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + PathExact: "/", + // PathPrefix: "/a", + PathRegex: "/b", + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP should only contain at most one of PathExact, PathPrefix, or PathRegex`, + }, + "permission has too many path components (3)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Description: strings.Repeat("x", 512), + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + // PathExact: "/", + PathPrefix: "/a", + PathRegex: "/b", + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP should only contain at most one of PathExact, PathPrefix, or PathRegex`, + }, + "permission has too many path components (4)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Description: strings.Repeat("x", 512), + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + PathExact: "/", + PathPrefix: "/a", + PathRegex: "/b", + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP should only contain at most one of PathExact, PathPrefix, or PathRegex`, + }, + "permission has invalid path exact": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Description: strings.Repeat("x", 512), + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + PathExact: "x", + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.PathExact doesn't start with '/': "x"`, + }, + "permission has invalid path prefix": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Description: strings.Repeat("x", 512), + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + PathPrefix: "x", + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.PathPrefix doesn't start with '/': "x"`, + }, + "permission header missing name": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Description: strings.Repeat("x", 512), + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{ + {Exact: "foo"}, + }, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Header[0] missing required Name field`, + }, + "permission header has too many parts (1)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{ + { + Name: "x-abc", + Present: true, + Exact: "foo", + // Regex: "foo", + // Prefix: "foo", + // Suffix: "foo", + }, + }, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Header[0] should only contain one of Present, Exact, Prefix, Suffix, or Regex`, + }, + "permission header has too many parts (2)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{ + { + Name: "x-abc", + Present: true, + // Exact: "foo", + Regex: "foo", + // Prefix: "foo", + // Suffix: "foo", + }, + }, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Header[0] should only contain one of Present, Exact, Prefix, Suffix, or Regex`, + }, + "permission header has too many parts (3)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{ + { + Name: "x-abc", + Present: true, + // Exact: "foo", + // Regex: "foo", + Prefix: "foo", + // Suffix: "foo", + }, + }, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Header[0] should only contain one of Present, Exact, Prefix, Suffix, or Regex`, + }, + "permission header has too many parts (4)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{ + { + Name: "x-abc", + Present: true, + // Exact: "foo", + // Regex: "foo", + // Prefix: "foo", + Suffix: "foo", + }, + }, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Header[0] should only contain one of Present, Exact, Prefix, Suffix, or Regex`, + }, + "permission header has too many parts (5)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{ + { + Name: "x-abc", + // Present: true, + Exact: "foo", + Regex: "foo", + // Prefix: "foo", + // Suffix: "foo", + }, + }, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Header[0] should only contain one of Present, Exact, Prefix, Suffix, or Regex`, + }, + "permission header has too many parts (6)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{ + { + Name: "x-abc", + // Present: true, + Exact: "foo", + // Regex: "foo", + Prefix: "foo", + // Suffix: "foo", + }, + }, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Header[0] should only contain one of Present, Exact, Prefix, Suffix, or Regex`, + }, + "permission header has too many parts (7)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{ + { + Name: "x-abc", + // Present: true, + Exact: "foo", + // Regex: "foo", + // Prefix: "foo", + Suffix: "foo", + }, + }, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Header[0] should only contain one of Present, Exact, Prefix, Suffix, or Regex`, + }, + "permission header has too many parts (8)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{ + { + Name: "x-abc", + // Present: true, + // Exact: "foo", + Regex: "foo", + Prefix: "foo", + // Suffix: "foo", + }, + }, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Header[0] should only contain one of Present, Exact, Prefix, Suffix, or Regex`, + }, + "permission header has too many parts (9)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{ + { + Name: "x-abc", + // Present: true, + // Exact: "foo", + Regex: "foo", + // Prefix: "foo", + Suffix: "foo", + }, + }, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Header[0] should only contain one of Present, Exact, Prefix, Suffix, or Regex`, + }, + "permission header has too many parts (10)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{ + { + Name: "x-abc", + // Present: true, + // Exact: "foo", + // Regex: "foo", + Prefix: "foo", + Suffix: "foo", + }, + }, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Header[0] should only contain one of Present, Exact, Prefix, Suffix, or Regex`, + }, + "permission header has too many parts (11)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{ + { + Name: "x-abc", + Present: true, + Exact: "foo", + Regex: "foo", + Prefix: "foo", + Suffix: "foo", + }, + }, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Header[0] should only contain one of Present, Exact, Prefix, Suffix, or Regex`, + }, + "permission invalid method": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Methods: []string{"YOINK"}, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Methods contains an invalid method "YOINK"`, + }, + "permission repeated method": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Methods: []string{"POST", "PUT", "POST"}, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP.Methods contains "POST" more than once`, + }, + "permission should not be empty (1)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + Header: []IntentionHTTPHeaderPermission{}, + Methods: []string{}, + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP should not be empty`, + }, + "permission should not be empty (2)": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{}, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions[0].HTTP should not be empty`, + }, + "permission kitchen sink": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + PathPrefix: "/foo", + Header: []IntentionHTTPHeaderPermission{ + { + Name: "x-abc", + Exact: "foo", + }, + { + Name: "x-xyz", + Present: true, + Invert: true, + }, + }, + Methods: []string{"POST", "PUT", "GET"}, + }, + }, + }, + }, + }, + }, + }, + "permissions not allowed on wildcarded destinations": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + // TODO: ent + Name: WildcardSpecifier, + Sources: []*SourceIntention{ + { + Name: "foo", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + PathPrefix: "/foo", + }, + }, + }, + }, + }, + }, + validateErr: `Sources[0].Permissions cannot be specified on intentions with wildcarded destinations`, + }, "L4 normalize": { entry: &ServiceIntentionsConfigEntry{ Kind: ServiceIntentions, @@ -472,6 +1154,47 @@ func TestServiceIntentionsConfigEntry(t *testing.T) { }, }, }, + "L7 normalize": { + entry: &ServiceIntentionsConfigEntry{ + Kind: ServiceIntentions, + Name: "test", + Sources: []*SourceIntention{ + { + Name: "bar", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionDeny, + HTTP: &IntentionHTTPPermission{ + Methods: []string{ + "get", "post", + }, + }, + }, + }, + }, + }, + }, + check: func(t *testing.T, entry *ServiceIntentionsConfigEntry) { + assert.Equal(t, []*SourceIntention{ + { + Name: "bar", + EnterpriseMeta: *defaultMeta, + Precedence: 9, + Type: IntentionSourceConsul, + Permissions: []*IntentionPermission{ + { + Action: IntentionActionDeny, + HTTP: &IntentionHTTPPermission{ + Methods: []string{ + "GET", "POST", + }, + }, + }, + }, + }, + }, entry.Sources) + }, + }, } for name, tc := range cases { tc := tc diff --git a/agent/structs/config_entry_test.go b/agent/structs/config_entry_test.go index 0b62db4f1..e5a92de3f 100644 --- a/agent/structs/config_entry_test.go +++ b/agent/structs/config_entry_test.go @@ -973,6 +973,57 @@ func TestDecodeConfigEntry(t *testing.T) { name = "bar" action = "allow" description = "bar desc" + }, + { + name = "l7" + permissions = [ + { + action = "deny" + http { + path_exact = "/admin" + header = [ + { + name = "hdr-present" + present = true + }, + { + name = "hdr-exact" + exact = "exact" + }, + { + name = "hdr-prefix" + prefix = "prefix" + }, + { + name = "hdr-suffix" + suffix = "suffix" + }, + { + name = "hdr-regex" + regex = "regex" + }, + { + name = "hdr-absent" + present = true + invert = true + } + ] + } + }, + { + action = "allow" + http { + path_prefix = "/v3/" + } + }, + { + action = "allow" + http { + path_regex = "/v[12]/.*" + methods = ["GET", "POST"] + } + } + ] } ] sources { @@ -999,6 +1050,57 @@ func TestDecodeConfigEntry(t *testing.T) { Name = "bar" Action = "allow" Description = "bar desc" + }, + { + Name = "l7" + Permissions = [ + { + Action = "deny" + HTTP { + PathExact = "/admin" + Header = [ + { + Name = "hdr-present" + Present = true + }, + { + Name = "hdr-exact" + Exact = "exact" + }, + { + Name = "hdr-prefix" + Prefix = "prefix" + }, + { + Name = "hdr-suffix" + Suffix = "suffix" + }, + { + Name = "hdr-regex" + Regex = "regex" + }, + { + Name = "hdr-absent" + Present = true + Invert = true + } + ] + } + }, + { + Action = "allow" + HTTP { + PathPrefix = "/v3/" + } + }, + { + Action = "allow" + HTTP { + PathRegex = "/v[12]/.*" + Methods = ["GET", "POST"] + } + } + ] } ] Sources { @@ -1026,6 +1128,57 @@ func TestDecodeConfigEntry(t *testing.T) { Action: "allow", Description: "bar desc", }, + { + Name: "l7", + Permissions: []*IntentionPermission{ + { + Action: "deny", + HTTP: &IntentionHTTPPermission{ + PathExact: "/admin", + Header: []IntentionHTTPHeaderPermission{ + { + Name: "hdr-present", + Present: true, + }, + { + Name: "hdr-exact", + Exact: "exact", + }, + { + Name: "hdr-prefix", + Prefix: "prefix", + }, + { + Name: "hdr-suffix", + Suffix: "suffix", + }, + { + Name: "hdr-regex", + Regex: "regex", + }, + { + Name: "hdr-absent", + Present: true, + Invert: true, + }, + }, + }, + }, + { + Action: "allow", + HTTP: &IntentionHTTPPermission{ + PathPrefix: "/v3/", + }, + }, + { + Action: "allow", + HTTP: &IntentionHTTPPermission{ + PathRegex: "/v[12]/.*", + Methods: []string{"GET", "POST"}, + }, + }, + }, + }, { Name: "*", Action: "deny", diff --git a/agent/structs/intention.go b/agent/structs/intention.go index f3e14b0b6..ef85743a6 100644 --- a/agent/structs/intention.go +++ b/agent/structs/intention.go @@ -55,7 +55,14 @@ type Intention struct { SourceType IntentionSourceType // Action is whether this is an allowlist or denylist intention. - Action IntentionAction + Action IntentionAction `json:",omitempty"` + + // Permissions is the list of additional L7 attributes that extend the + // intention definition. + // + // NOTE: This field is not editable unless editing the underlying + // service-intentions config entry directly. + Permissions []*IntentionPermission `bexpr:"-" json:",omitempty"` // DefaultAddr is not used. // Deprecated: DefaultAddr is not used and may be removed in a future version. @@ -77,10 +84,11 @@ type Intention struct { // or modified. CreatedAt, UpdatedAt time.Time `mapstructure:"-" bexpr:"-"` - // Hash of the contents of the intention + // Hash of the contents of the intention. This is only necessary for legacy + // intention replication purposes. // - // This is needed mainly for replication purposes. When replicating from - // one DC to another keeping the content Hash will allow us to detect + // This is needed mainly for legacy replication purposes. When replicating + // from one DC to another keeping the content Hash will allow us to detect // content changes more efficiently than checking every single field Hash []byte `bexpr:"-" json:",omitempty"` @@ -89,6 +97,12 @@ type Intention struct { func (t *Intention) Clone() *Intention { t2 := *t + if len(t.Permissions) > 0 { + t2.Permissions = make([]*IntentionPermission, 0, len(t.Permissions)) + for _, perm := range t.Permissions { + t2.Permissions = append(t2.Permissions, perm.Clone()) + } + } t2.Meta = cloneStringStringMap(t.Meta) t2.Hash = nil return &t2 @@ -267,6 +281,11 @@ func (x *Intention) Validate() error { "Action must be set to 'allow' or 'deny'")) } + if len(x.Permissions) > 0 { + result = multierror.Append(result, fmt.Errorf( + "Permissions must not be set when using the legacy APIs")) + } + switch x.SourceType { case IntentionSourceConsul: default: @@ -365,11 +384,25 @@ func (x *Intention) countExact(ns, n string) int { // String returns a human-friendly string for this intention. func (x *Intention) String() string { - return fmt.Sprintf("%s %s/%s => %s/%s (ID: %s, Precedence: %d)", - strings.ToUpper(string(x.Action)), + var idPart string + if x.ID != "" { + idPart = "ID: " + x.ID + ", " + } + + var detailPart string + if len(x.Permissions) > 0 { + detailPart = fmt.Sprintf("Permissions: %d", len(x.Permissions)) + } else { + detailPart = "Action: " + strings.ToUpper(string(x.Action)) + } + + return fmt.Sprintf("%s/%s => %s/%s (%sPrecedence: %d, %s)", x.SourceNS, x.SourceName, x.DestinationNS, x.DestinationName, - x.ID, x.Precedence) + idPart, + x.Precedence, + detailPart, + ) } // LegacyEstimateSize returns an estimate (in bytes) of the size of this structure when encoded. @@ -397,21 +430,22 @@ func (x *Intention) DestinationServiceName() ServiceName { // NOTE this is just used to manipulate user-provided data before an insert // The RPC execution will do Normalize + Validate for us. -func (x *Intention) ToConfigEntry() *ServiceIntentionsConfigEntry { +func (x *Intention) ToConfigEntry(legacy bool) *ServiceIntentionsConfigEntry { return &ServiceIntentionsConfigEntry{ Kind: ServiceIntentions, Name: x.DestinationName, EnterpriseMeta: *x.DestinationEnterpriseMeta(), - Sources: []*SourceIntention{x.ToSourceIntention()}, + Sources: []*SourceIntention{x.ToSourceIntention(legacy)}, } } -func (x *Intention) ToSourceIntention() *SourceIntention { - return &SourceIntention{ +func (x *Intention) ToSourceIntention(legacy bool) *SourceIntention { + src := &SourceIntention{ Name: x.SourceName, EnterpriseMeta: *x.SourceEnterpriseMeta(), Action: x.Action, - Precedence: 0, // Ignore, let it be computed. + Permissions: nil, // explicitly not symmetric with the old APIs + Precedence: 0, // Ignore, let it be computed. LegacyID: x.ID, Type: x.SourceType, Description: x.Description, @@ -419,6 +453,10 @@ func (x *Intention) ToSourceIntention() *SourceIntention { LegacyCreateTime: nil, // Ignore LegacyUpdateTime: nil, // Ignore } + if !legacy { + src.Permissions = x.Permissions + } + return src } // IntentionAction is the action that the intention represents. This diff --git a/agent/structs/intention_test.go b/agent/structs/intention_test.go index 924da2559..be6306018 100644 --- a/agent/structs/intention_test.go +++ b/agent/structs/intention_test.go @@ -359,3 +359,97 @@ func TestIntention_SetHash(t *testing.T) { } require.Equal(t, expected, i.Hash) } + +func TestIntention_String(t *testing.T) { + type testcase struct { + ixn *Intention + expect string + } + + testID := generateUUID() + + cases := map[string]testcase{ + "legacy allow": { + &Intention{ + ID: testID, + SourceName: "foo", + DestinationName: "bar", + Action: IntentionActionAllow, + }, + `default/foo => default/bar (ID: ` + testID + `, Precedence: 9, Action: ALLOW)`, + }, + "legacy deny": { + &Intention{ + ID: testID, + SourceName: "foo", + DestinationName: "bar", + Action: IntentionActionDeny, + }, + `default/foo => default/bar (ID: ` + testID + `, Precedence: 9, Action: DENY)`, + }, + "L4 allow": { + &Intention{ + SourceName: "foo", + DestinationName: "bar", + Action: IntentionActionAllow, + }, + `default/foo => default/bar (Precedence: 9, Action: ALLOW)`, + }, + "L4 deny": { + &Intention{ + SourceName: "foo", + DestinationName: "bar", + Action: IntentionActionDeny, + }, + `default/foo => default/bar (Precedence: 9, Action: DENY)`, + }, + "L7 one perm": { + &Intention{ + SourceName: "foo", + DestinationName: "bar", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + PathPrefix: "/foo", + }, + }, + }, + }, + `default/foo => default/bar (Precedence: 9, Permissions: 1)`, + }, + "L7 two perms": { + &Intention{ + SourceName: "foo", + DestinationName: "bar", + Permissions: []*IntentionPermission{ + { + Action: IntentionActionDeny, + HTTP: &IntentionHTTPPermission{ + PathExact: "/foo/admin", + }, + }, + { + Action: IntentionActionAllow, + HTTP: &IntentionHTTPPermission{ + PathPrefix: "/foo", + }, + }, + }, + }, + `default/foo => default/bar (Precedence: 9, Permissions: 2)`, + }, + } + + for name, tc := range cases { + tc := tc + // Add a bunch of required fields. + tc.ixn.DefaultNamespaces(DefaultEnterpriseMeta()) + tc.ixn.UpdatePrecedence() + + t.Run(name, func(t *testing.T) { + got := tc.ixn.String() + require.Equal(t, tc.expect, got) + }) + } +} diff --git a/agent/xds/envoy_versioning.go b/agent/xds/envoy_versioning.go index 1ed297f4d..14064b1c9 100644 --- a/agent/xds/envoy_versioning.go +++ b/agent/xds/envoy_versioning.go @@ -12,8 +12,37 @@ var ( // minSupportedVersion is the oldest mainline version we support. This should always be // the zero'th point release of the last element of proxysupport.EnvoyVersions. minSupportedVersion = version.Must(version.NewVersion("1.12.0")) + + specificUnsupportedVersions = []unsupportedVersion{ + { + Version: version.Must(version.NewVersion("1.12.0")), + UpgradeTo: "1.12.3+", + Why: "does not support RBAC rules using url_path", + }, + { + Version: version.Must(version.NewVersion("1.12.1")), + UpgradeTo: "1.12.3+", + Why: "does not support RBAC rules using url_path", + }, + { + Version: version.Must(version.NewVersion("1.12.2")), + UpgradeTo: "1.12.3+", + Why: "does not support RBAC rules using url_path", + }, + { + Version: version.Must(version.NewVersion("1.13.0")), + UpgradeTo: "1.13.1+", + Why: "does not support RBAC rules using url_path", + }, + } ) +type unsupportedVersion struct { + Version *version.Version + UpgradeTo string + Why string +} + type supportedProxyFeatures struct { // add version dependent feature flags here } @@ -39,6 +68,18 @@ func determineSupportedProxyFeaturesFromVersion(version *version.Version) (suppo return supportedProxyFeatures{}, fmt.Errorf("Envoy %s is too old and is not supported by Consul", version) } + for _, uv := range specificUnsupportedVersions { + if version.Equal(uv.Version) { + return supportedProxyFeatures{}, fmt.Errorf( + "Envoy %s is too old of a point release and is not supported by Consul because it %s. "+ + "Please upgrade to version %s.", + version, + uv.Why, + uv.UpgradeTo, + ) + } + } + return supportedProxyFeatures{}, nil } diff --git a/agent/xds/envoy_versioning_test.go b/agent/xds/envoy_versioning_test.go index 4de6103fa..e9f8d5308 100644 --- a/agent/xds/envoy_versioning_test.go +++ b/agent/xds/envoy_versioning_test.go @@ -5,6 +5,7 @@ import ( envoycore "github.com/envoyproxy/go-control-plane/envoy/api/v2/core" envoytype "github.com/envoyproxy/go-control-plane/envoy/type" + "github.com/hashicorp/consul/sdk/testutil" "github.com/hashicorp/go-version" "github.com/stretchr/testify/require" ) @@ -68,3 +69,49 @@ func TestDetermineEnvoyVersionFromNode(t *testing.T) { }) } } + +func TestDetermineSupportedProxyFeaturesFromString(t *testing.T) { + const ( + err1_12 = "is too old of a point release and is not supported by Consul because it does not support RBAC rules using url_path. Please upgrade to version 1.12.3+." + err1_13 = "is too old of a point release and is not supported by Consul because it does not support RBAC rules using url_path. Please upgrade to version 1.13.1+." + errTooOld = "is too old and is not supported by Consul" + ) + + type testcase struct { + expect supportedProxyFeatures + expectErr string + } + + // Just the bad versions + cases := map[string]testcase{ + "1.9.0": {expectErr: "Envoy 1.9.0 " + errTooOld}, + "1.10.0": {expectErr: "Envoy 1.10.0 " + errTooOld}, + "1.11.0": {expectErr: "Envoy 1.11.0 " + errTooOld}, + "1.12.0": {expectErr: "Envoy 1.12.0 " + err1_12}, + "1.12.1": {expectErr: "Envoy 1.12.1 " + err1_12}, + "1.12.2": {expectErr: "Envoy 1.12.2 " + err1_12}, + "1.13.0": {expectErr: "Envoy 1.13.0 " + err1_13}, + } + + // Insert a bunch of valid versions. + for _, v := range []string{ + "1.12.3", "1.12.4", "1.12.5", "1.12.6", "1.12.7", + "1.13.1", "1.13.2", "1.13.3", "1.13.4", "1.13.6", "1.14.1", + "1.14.2", "1.14.3", "1.14.4", "1.14.5", + "1.15.0", "1.15.1", "1.15.2", + } { + cases[v] = testcase{expect: supportedProxyFeatures{}} + } + + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + sf, err := determineSupportedProxyFeaturesFromString(name) + if tc.expectErr == "" { + require.Equal(t, tc.expect, sf) + } else { + testutil.RequireErrorContains(t, err, tc.expectErr) + } + }) + } +} diff --git a/agent/xds/rbac.go b/agent/xds/rbac.go index da9f03b95..19927c1c6 100644 --- a/agent/xds/rbac.go +++ b/agent/xds/rbac.go @@ -3,8 +3,10 @@ package xds import ( "fmt" "sort" + "strings" envoylistener "github.com/envoyproxy/go-control-plane/envoy/api/v2/listener" + envoyroute "github.com/envoyproxy/go-control-plane/envoy/api/v2/route" envoyhttprbac "github.com/envoyproxy/go-control-plane/envoy/config/filter/http/rbac/v2" envoyhttp "github.com/envoyproxy/go-control-plane/envoy/config/filter/network/http_connection_manager/v2" envoynetrbac "github.com/envoyproxy/go-control-plane/envoy/config/filter/network/rbac/v2" @@ -14,7 +16,7 @@ import ( ) func makeRBACNetworkFilter(intentions structs.Intentions, intentionDefaultAllow bool) (*envoylistener.Filter, error) { - rules, err := makeRBACRules(intentions, intentionDefaultAllow) + rules, err := makeRBACRules(intentions, intentionDefaultAllow, false) if err != nil { return nil, err } @@ -27,7 +29,7 @@ func makeRBACNetworkFilter(intentions structs.Intentions, intentionDefaultAllow } func makeRBACHTTPFilter(intentions structs.Intentions, intentionDefaultAllow bool) (*envoyhttp.HttpFilter, error) { - rules, err := makeRBACRules(intentions, intentionDefaultAllow) + rules, err := makeRBACRules(intentions, intentionDefaultAllow, true) if err != nil { return nil, err } @@ -38,16 +40,238 @@ func makeRBACHTTPFilter(intentions structs.Intentions, intentionDefaultAllow boo return makeEnvoyHTTPFilter("envoy.filters.http.rbac", cfg) } -type rbacIntention struct { - Source structs.ServiceName - NotSources []structs.ServiceName - Allow bool - Precedence int - Skip bool +func intentionListToIntermediateRBACForm(intentions structs.Intentions, isHTTP bool) []*rbacIntention { + sort.Sort(structs.IntentionPrecedenceSorter(intentions)) + + // Omit any lower-precedence intentions that share the same source. + intentions = removeSameSourceIntentions(intentions) + + rbacIxns := make([]*rbacIntention, 0, len(intentions)) + for _, ixn := range intentions { + rixn := intentionToIntermediateRBACForm(ixn, isHTTP) + rbacIxns = append(rbacIxns, rixn) + } + return rbacIxns } -func (r *rbacIntention) Simplify() { +func removeSourcePrecedence(rbacIxns []*rbacIntention, intentionDefaultAction intentionAction) []*rbacIntention { + if len(rbacIxns) == 0 { + return nil + } + + // Remove source precedence: + // + // First walk backwards and add each intention to all subsequent statements + // (via AND NOT $x). + // + // If it is L4 and has the same action as the default intention action then + // mark the rule itself for erasure. + numRetained := 0 + for i := len(rbacIxns) - 1; i >= 0; i-- { + for j := i + 1; j < len(rbacIxns); j++ { + if rbacIxns[j].Skip { + continue + } + // [i] is the intention candidate that we are distributing + // [j] is the thing to maybe NOT [i] from + if ixnSourceMatches(rbacIxns[i].Source, rbacIxns[j].Source) { + rbacIxns[j].NotSources = append(rbacIxns[j].NotSources, rbacIxns[i].Source) + } + } + if rbacIxns[i].Action == intentionDefaultAction { + // Lower precedence intentions that match the default intention + // action are skipped, since they're handled by the default + // catch-all. + rbacIxns[i].Skip = true // mark for deletion + } else { + numRetained++ + } + } + // At this point precedence doesn't matter for the source element. + + // Remove skipped intentions and also compute the final Principals for each + // intention. + out := make([]*rbacIntention, 0, numRetained) + for _, rixn := range rbacIxns { + if rixn.Skip { + continue + } + + rixn.ComputedPrincipal = rixn.FlattenPrincipal() + out = append(out, rixn) + } + + return out +} + +func removeIntentionPrecedence(rbacIxns []*rbacIntention, intentionDefaultAction intentionAction) []*rbacIntention { + // Remove source precedence. After this completes precedence doesn't matter + // between any two intentions. + rbacIxns = removeSourcePrecedence(rbacIxns, intentionDefaultAction) + + for _, rbacIxn := range rbacIxns { + // Remove permission precedence. After this completes precedence + // doesn't matter between any two permissions on this intention. + rbacIxn.Permissions = removePermissionPrecedence(rbacIxn.Permissions, intentionDefaultAction) + } + + return rbacIxns +} + +func removePermissionPrecedence(perms []*rbacPermission, intentionDefaultAction intentionAction) []*rbacPermission { + if len(perms) == 0 { + return nil + } + + // First walk backwards and add each permission to all subsequent + // statements (via AND NOT $x). + // + // If it has the same action as the default intention action then mark the + // permission itself for erasure. + numRetained := 0 + for i := len(perms) - 1; i >= 0; i-- { + for j := i + 1; j < len(perms); j++ { + if perms[j].Skip { + continue + } + // [i] is the permission candidate that we are distributing + // [j] is the thing to maybe NOT [i] from + perms[j].NotPerms = append( + perms[j].NotPerms, + perms[i].Perm, + ) + } + if perms[i].Action == intentionDefaultAction { + // Lower precedence permissions that match the default intention + // action are skipped, since they're handled by the default + // catch-all. + perms[i].Skip = true // mark for deletion + } else { + numRetained++ + } + } + + // Remove skipped permissions and also compute the final Permissions for each item. + out := make([]*rbacPermission, 0, numRetained) + for _, perm := range perms { + if perm.Skip { + continue + } + + perm.ComputedPermission = perm.Flatten() + out = append(out, perm) + } + + return out +} + +func intentionToIntermediateRBACForm(ixn *structs.Intention, isHTTP bool) *rbacIntention { + rixn := &rbacIntention{ + Source: ixn.SourceServiceName(), + Precedence: ixn.Precedence, + } + if len(ixn.Permissions) > 0 { + if isHTTP { + rixn.Action = intentionActionLayer7 + rixn.Permissions = make([]*rbacPermission, 0, len(ixn.Permissions)) + for _, perm := range ixn.Permissions { + rixn.Permissions = append(rixn.Permissions, &rbacPermission{ + Definition: perm, + Action: intentionActionFromString(perm.Action), + Perm: convertPermission(perm), + }) + } + } else { + // In case L7 intentions slip through to here, treat them as deny intentions. + rixn.Action = intentionActionDeny + } + } else { + rixn.Action = intentionActionFromString(ixn.Action) + } + + return rixn +} + +type intentionAction int + +const ( + intentionActionDeny intentionAction = iota + intentionActionAllow + intentionActionLayer7 +) + +func intentionActionFromBool(v bool) intentionAction { + if v { + return intentionActionAllow + } else { + return intentionActionDeny + } +} +func intentionActionFromString(s structs.IntentionAction) intentionAction { + if s == structs.IntentionActionAllow { + return intentionActionAllow + } + return intentionActionDeny +} + +type rbacIntention struct { + Source structs.ServiceName + NotSources []structs.ServiceName + Action intentionAction + Permissions []*rbacPermission + Precedence int + + // Skip is field used to indicate that this intention can be deleted in the + // final pass. Items marked as true should generally not escape the method + // that marked them. + Skip bool + + ComputedPrincipal *envoyrbac.Principal +} + +func (r *rbacIntention) FlattenPrincipal() *envoyrbac.Principal { r.NotSources = simplifyNotSourceSlice(r.NotSources) + + if len(r.NotSources) == 0 { + return idPrincipal(r.Source) + } + + andIDs := make([]*envoyrbac.Principal, 0, len(r.NotSources)+1) + andIDs = append(andIDs, idPrincipal(r.Source)) + for _, src := range r.NotSources { + andIDs = append(andIDs, notPrincipal( + idPrincipal(src), + )) + } + return andPrincipals(andIDs) +} + +type rbacPermission struct { + Definition *structs.IntentionPermission + + Action intentionAction + Perm *envoyrbac.Permission + NotPerms []*envoyrbac.Permission + + // Skip is field used to indicate that this permission can be deleted in + // the final pass. Items marked as true should generally not escape the + // method that marked them. + Skip bool + + ComputedPermission *envoyrbac.Permission +} + +func (p *rbacPermission) Flatten() *envoyrbac.Permission { + if len(p.NotPerms) == 0 { + return p.Perm + } + + parts := make([]*envoyrbac.Permission, 0, len(p.NotPerms)+1) + parts = append(parts, p.Perm) + for _, notPerm := range p.NotPerms { + parts = append(parts, notPermission(notPerm)) + } + return andPermissions(parts) } func simplifyNotSourceSlice(notSources []structs.ServiceName) []structs.ServiceName { @@ -132,7 +356,7 @@ func simplifyNotSourceSlice(notSources []structs.ServiceName) []structs.ServiceN // : DENY // // Which really is just an allow-list of [A, C AND NOT(B)] -func makeRBACRules(intentions structs.Intentions, intentionDefaultAllow bool) (*envoyrbac.RBAC, error) { +func makeRBACRules(intentions structs.Intentions, intentionDefaultAllow bool, isHTTP bool) (*envoyrbac.RBAC, error) { // Note that we DON'T explicitly validate the trust-domain matches ours. // // For now we don't validate the trust domain of the _destination_ at all. @@ -147,20 +371,11 @@ func makeRBACRules(intentions structs.Intentions, intentionDefaultAllow bool) (* // TODO(banks,rb): Implement revocation list checking? - // Omit any lower-precedence intentions that share the same source. - intentions = removeSameSourceIntentions(intentions) - // First build up just the basic principal matches. - rbacIxns := make([]*rbacIntention, 0, len(intentions)) - for _, ixn := range intentions { - rbacIxns = append(rbacIxns, &rbacIntention{ - Source: ixn.SourceServiceName(), - Allow: (ixn.Action == structs.IntentionActionAllow), - Precedence: ixn.Precedence, - }) - } + rbacIxns := intentionListToIntermediateRBACForm(intentions, isHTTP) // Normalize: if we are in default-deny then all intentions must be allows and vice versa + intentionDefaultAction := intentionActionFromBool(intentionDefaultAllow) var rbacAction envoyrbac.RBAC_Action if intentionDefaultAllow { @@ -173,69 +388,46 @@ func makeRBACRules(intentions structs.Intentions, intentionDefaultAllow bool) (* rbacAction = envoyrbac.RBAC_ALLOW } - // First walk backwards and if we encounter an intention with an action - // that is the same as the default intention action, add it to all - // subsequent statements (via AND NOT $x) and mark the rule itself for - // erasure. - // - // i.e. for a default-deny setup we look for denies. - if len(rbacIxns) > 0 { - for i := len(rbacIxns) - 1; i >= 0; i-- { - if rbacIxns[i].Allow == intentionDefaultAllow { - for j := i + 1; j < len(rbacIxns); j++ { - if rbacIxns[j].Skip { - continue - } - // [i] is the intention candidate that we are distributing - // [j] is the thing to maybe NOT [i] from - if ixnSourceMatches(rbacIxns[i].Source, rbacIxns[j].Source) { - rbacIxns[j].NotSources = append(rbacIxns[j].NotSources, rbacIxns[i].Source) - } - } - // since this is default-FOO, any trailing FOO intentions will just evaporate - rbacIxns[i].Skip = true // mark for deletion - } - } - } - // At this point precedence doesn't matter since all roads lead to the same action. - - var principals []*envoyrbac.Principal - for _, rbacIxn := range rbacIxns { - if rbacIxn.Skip { - continue - } - - // NOTE: at this point "rbacIxn.Allow != intentionDefaultAllow" - - rbacIxn.Simplify() - - if len(rbacIxn.NotSources) > 0 { - andIDs := make([]*envoyrbac.Principal, 0, len(rbacIxn.NotSources)+1) - andIDs = append(andIDs, idPrincipal(rbacIxn.Source)) - for _, src := range rbacIxn.NotSources { - andIDs = append(andIDs, notPrincipal( - idPrincipal(src), - )) - } - principals = append(principals, andPrincipals(andIDs)) - } else { - principals = append(principals, idPrincipal(rbacIxn.Source)) - } - } + // Remove source and permissions precedence. + rbacIxns = removeIntentionPrecedence(rbacIxns, intentionDefaultAction) + // For L4: we should generate one big Policy listing all Principals + // For L7: we should generate one Policy per Principal and list all of the Permissions rbac := &envoyrbac.RBAC{ - Action: rbacAction, + Action: rbacAction, + Policies: make(map[string]*envoyrbac.Policy), } - if len(principals) > 0 { - policy := &envoyrbac.Policy{ - Principals: principals, + + var principalsL4 []*envoyrbac.Principal + for i, rbacIxn := range rbacIxns { + if len(rbacIxn.Permissions) > 0 { + if !isHTTP { + panic("invalid state: L7 permissions present for TCP service") + } + // For L7: we should generate one Policy per Principal and list all of the Permissions + policy := &envoyrbac.Policy{ + Principals: []*envoyrbac.Principal{rbacIxn.ComputedPrincipal}, + Permissions: make([]*envoyrbac.Permission, 0, len(rbacIxn.Permissions)), + } + for _, perm := range rbacIxn.Permissions { + policy.Permissions = append(policy.Permissions, perm.ComputedPermission) + } + rbac.Policies[fmt.Sprintf("consul-intentions-layer7-%d", i)] = policy + } else { + // For L4: we should generate one big Policy listing all Principals + principalsL4 = append(principalsL4, rbacIxn.ComputedPrincipal) + } + } + if len(principalsL4) > 0 { + rbac.Policies["consul-intentions-layer4"] = &envoyrbac.Policy{ + Principals: principalsL4, Permissions: []*envoyrbac.Permission{anyPermission()}, } - rbac.Policies = map[string]*envoyrbac.Policy{ - "consul-intentions": policy, - } } + if len(rbac.Policies) == 0 { + rbac.Policies = nil + } return rbac, nil } @@ -267,14 +459,6 @@ func removeSameSourceIntentions(intentions structs.Intentions) structs.Intention return out } -type sourceMatch int - -const ( - sourceMatchIgnore sourceMatch = 0 - sourceMatchSuperset sourceMatch = 1 - matchSameSubset sourceMatch = 2 -) - // ixnSourceMatches deterines if the 'tester' service name is matched by the // 'against' service name via wildcard rules. // @@ -372,3 +556,142 @@ func anyPermission() *envoyrbac.Permission { Rule: &envoyrbac.Permission_Any{Any: true}, } } + +func convertPermission(perm *structs.IntentionPermission) *envoyrbac.Permission { + // NOTE: this does not do anything with perm.Action + if perm.HTTP == nil { + return anyPermission() + } + + var parts []*envoyrbac.Permission + + switch { + case perm.HTTP.PathExact != "": + parts = append(parts, &envoyrbac.Permission{ + Rule: &envoyrbac.Permission_UrlPath{ + UrlPath: &envoymatcher.PathMatcher{ + Rule: &envoymatcher.PathMatcher_Path{ + Path: &envoymatcher.StringMatcher{ + MatchPattern: &envoymatcher.StringMatcher_Exact{ + Exact: perm.HTTP.PathExact, + }, + }, + }, + }, + }, + }) + case perm.HTTP.PathPrefix != "": + parts = append(parts, &envoyrbac.Permission{ + Rule: &envoyrbac.Permission_UrlPath{ + UrlPath: &envoymatcher.PathMatcher{ + Rule: &envoymatcher.PathMatcher_Path{ + Path: &envoymatcher.StringMatcher{ + MatchPattern: &envoymatcher.StringMatcher_Prefix{ + Prefix: perm.HTTP.PathPrefix, + }, + }, + }, + }, + }, + }) + case perm.HTTP.PathRegex != "": + parts = append(parts, &envoyrbac.Permission{ + Rule: &envoyrbac.Permission_UrlPath{ + UrlPath: &envoymatcher.PathMatcher{ + Rule: &envoymatcher.PathMatcher_Path{ + Path: &envoymatcher.StringMatcher{ + MatchPattern: &envoymatcher.StringMatcher_SafeRegex{ + SafeRegex: makeEnvoyRegexMatch(perm.HTTP.PathRegex), + }, + }, + }, + }, + }, + }) + } + + for _, hdr := range perm.HTTP.Header { + eh := &envoyroute.HeaderMatcher{ + Name: hdr.Name, + } + + switch { + case hdr.Exact != "": + eh.HeaderMatchSpecifier = &envoyroute.HeaderMatcher_ExactMatch{ + ExactMatch: hdr.Exact, + } + case hdr.Regex != "": + eh.HeaderMatchSpecifier = &envoyroute.HeaderMatcher_SafeRegexMatch{ + SafeRegexMatch: makeEnvoyRegexMatch(hdr.Regex), + } + case hdr.Prefix != "": + eh.HeaderMatchSpecifier = &envoyroute.HeaderMatcher_PrefixMatch{ + PrefixMatch: hdr.Prefix, + } + case hdr.Suffix != "": + eh.HeaderMatchSpecifier = &envoyroute.HeaderMatcher_SuffixMatch{ + SuffixMatch: hdr.Suffix, + } + case hdr.Present: + eh.HeaderMatchSpecifier = &envoyroute.HeaderMatcher_PresentMatch{ + PresentMatch: true, + } + default: + continue // skip this impossible situation + } + + if hdr.Invert { + eh.InvertMatch = true + } + + parts = append(parts, &envoyrbac.Permission{ + Rule: &envoyrbac.Permission_Header{ + Header: eh, + }, + }) + } + + if len(perm.HTTP.Methods) > 0 { + methodHeaderRegex := strings.Join(perm.HTTP.Methods, "|") + + eh := &envoyroute.HeaderMatcher{ + Name: ":method", + HeaderMatchSpecifier: &envoyroute.HeaderMatcher_SafeRegexMatch{ + SafeRegexMatch: makeEnvoyRegexMatch(methodHeaderRegex), + }, + } + + parts = append(parts, &envoyrbac.Permission{ + Rule: &envoyrbac.Permission_Header{ + Header: eh, + }, + }) + } + + // NOTE: if for some reason we errantly allow a permission to be defined + // with a body of "http{}" then we'll end up treating that like "ANY" here. + return andPermissions(parts) +} + +func notPermission(perm *envoyrbac.Permission) *envoyrbac.Permission { + return &envoyrbac.Permission{ + Rule: &envoyrbac.Permission_NotRule{NotRule: perm}, + } +} + +func andPermissions(perms []*envoyrbac.Permission) *envoyrbac.Permission { + switch len(perms) { + case 0: + return anyPermission() + case 1: + return perms[0] + default: + return &envoyrbac.Permission{ + Rule: &envoyrbac.Permission_AndRules{ + AndRules: &envoyrbac.Permission_Set{ + Rules: perms, + }, + }, + } + } +} diff --git a/agent/xds/rbac_test.go b/agent/xds/rbac_test.go index 5181c525d..8c988cce8 100644 --- a/agent/xds/rbac_test.go +++ b/agent/xds/rbac_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestMakeRBACNetworkFilter(t *testing.T) { +func TestMakeRBACNetworkAndHTTPFilters(t *testing.T) { testIntention := func(t *testing.T, src, dst string, action structs.IntentionAction) *structs.Intention { t.Helper() ixn := structs.TestIntention(t) @@ -25,6 +25,11 @@ func TestMakeRBACNetworkFilter(t *testing.T) { testSourceIntention := func(src string, action structs.IntentionAction) *structs.Intention { return testIntention(t, src, "api", action) } + testSourcePermIntention := func(src string, perms ...*structs.IntentionPermission) *structs.Intention { + ixn := testIntention(t, src, "api", "") + ixn.Permissions = perms + return ixn + } sorted := func(ixns ...*structs.Intention) structs.Intentions { sort.SliceStable(ixns, func(i, j int) bool { return ixns[j].Precedence < ixns[i].Precedence @@ -97,17 +102,163 @@ func TestMakeRBACNetworkFilter(t *testing.T) { testSourceIntention("*", structs.IntentionActionDeny), ), }, + "default-deny-two-path-deny-and-path-allow": { + intentionDefaultAllow: false, + intentions: sorted( + testSourcePermIntention("web", + &structs.IntentionPermission{ + Action: structs.IntentionActionDeny, + HTTP: &structs.IntentionHTTPPermission{ + PathExact: "/v1/secret", + }, + }, + &structs.IntentionPermission{ + Action: structs.IntentionActionDeny, + HTTP: &structs.IntentionHTTPPermission{ + PathExact: "/v1/admin", + }, + }, + &structs.IntentionPermission{ + Action: structs.IntentionActionAllow, + HTTP: &structs.IntentionHTTPPermission{ + PathPrefix: "/", + }, + }, + ), + ), + }, + "default-allow-two-path-deny-and-path-allow": { + intentionDefaultAllow: true, + intentions: sorted( + testSourcePermIntention("web", + &structs.IntentionPermission{ + Action: structs.IntentionActionDeny, + HTTP: &structs.IntentionHTTPPermission{ + PathExact: "/v1/secret", + }, + }, + &structs.IntentionPermission{ + Action: structs.IntentionActionDeny, + HTTP: &structs.IntentionHTTPPermission{ + PathExact: "/v1/admin", + }, + }, + &structs.IntentionPermission{ + Action: structs.IntentionActionAllow, + HTTP: &structs.IntentionHTTPPermission{ + PathPrefix: "/", + }, + }, + ), + ), + }, + "default-deny-single-intention-with-kitchen-sink-perms": { + intentionDefaultAllow: false, + intentions: sorted( + testSourcePermIntention("web", + &structs.IntentionPermission{ + Action: structs.IntentionActionDeny, + HTTP: &structs.IntentionHTTPPermission{ + PathExact: "/v1/secret", + }, + }, + &structs.IntentionPermission{ + Action: structs.IntentionActionAllow, + HTTP: &structs.IntentionHTTPPermission{ + PathPrefix: "/v1", + }, + }, + &structs.IntentionPermission{ + Action: structs.IntentionActionAllow, + HTTP: &structs.IntentionHTTPPermission{ + PathRegex: "/v[123]", + Methods: []string{"GET", "HEAD", "OPTIONS"}, + }, + }, + &structs.IntentionPermission{ + Action: structs.IntentionActionAllow, + HTTP: &structs.IntentionHTTPPermission{ + Header: []structs.IntentionHTTPHeaderPermission{ + {Name: "x-foo", Present: true}, + {Name: "x-bar", Exact: "xyz"}, + {Name: "x-dib", Prefix: "gaz"}, + {Name: "x-gir", Suffix: "zim"}, + {Name: "x-zim", Regex: "gi[rR]"}, + {Name: "z-foo", Present: true, Invert: true}, + {Name: "z-bar", Exact: "xyz", Invert: true}, + {Name: "z-dib", Prefix: "gaz", Invert: true}, + {Name: "z-gir", Suffix: "zim", Invert: true}, + {Name: "z-zim", Regex: "gi[rR]", Invert: true}, + }, + }, + }, + ), + ), + }, + "default-allow-single-intention-with-kitchen-sink-perms": { + intentionDefaultAllow: true, + intentions: sorted( + testSourcePermIntention("web", + &structs.IntentionPermission{ + Action: structs.IntentionActionAllow, + HTTP: &structs.IntentionHTTPPermission{ + PathExact: "/v1/secret", + }, + }, + &structs.IntentionPermission{ + Action: structs.IntentionActionDeny, + HTTP: &structs.IntentionHTTPPermission{ + PathPrefix: "/v1", + }, + }, + &structs.IntentionPermission{ + Action: structs.IntentionActionDeny, + HTTP: &structs.IntentionHTTPPermission{ + PathRegex: "/v[123]", + Methods: []string{"GET", "HEAD", "OPTIONS"}, + }, + }, + &structs.IntentionPermission{ + Action: structs.IntentionActionDeny, + HTTP: &structs.IntentionHTTPPermission{ + Header: []structs.IntentionHTTPHeaderPermission{ + {Name: "x-foo", Present: true}, + {Name: "x-bar", Exact: "xyz"}, + {Name: "x-dib", Prefix: "gaz"}, + {Name: "x-gir", Suffix: "zim"}, + {Name: "x-zim", Regex: "gi[rR]"}, + {Name: "z-foo", Present: true, Invert: true}, + {Name: "z-bar", Exact: "xyz", Invert: true}, + {Name: "z-dib", Prefix: "gaz", Invert: true}, + {Name: "z-gir", Suffix: "zim", Invert: true}, + {Name: "z-zim", Regex: "gi[rR]", Invert: true}, + }, + }, + }, + ), + ), + }, } for name, tt := range tests { tt := tt t.Run(name, func(t *testing.T) { - filter, err := makeRBACNetworkFilter(tt.intentions, tt.intentionDefaultAllow) - require.NoError(t, err) + t.Run("network filter", func(t *testing.T) { + filter, err := makeRBACNetworkFilter(tt.intentions, tt.intentionDefaultAllow) + require.NoError(t, err) - gotJSON := protoToJSON(t, filter) + gotJSON := protoToJSON(t, filter) - require.JSONEq(t, golden(t, filepath.Join("rbac", name), "", gotJSON), gotJSON) + require.JSONEq(t, golden(t, filepath.Join("rbac", name), "", gotJSON), gotJSON) + }) + t.Run("http filter", func(t *testing.T) { + filter, err := makeRBACHTTPFilter(tt.intentions, tt.intentionDefaultAllow) + require.NoError(t, err) + + gotJSON := protoToJSON(t, filter) + + require.JSONEq(t, golden(t, filepath.Join("rbac", name+"--httpfilter"), "", gotJSON), gotJSON) + }) }) } } diff --git a/agent/xds/testdata/rbac/default-allow-kitchen-sink--httpfilter.golden b/agent/xds/testdata/rbac/default-allow-kitchen-sink--httpfilter.golden new file mode 100644 index 000000000..9d34bbd5a --- /dev/null +++ b/agent/xds/testdata/rbac/default-allow-kitchen-sink--httpfilter.golden @@ -0,0 +1,97 @@ +{ + "name": "envoy.filters.http.rbac", + "config": { + "rules": { + "action": "DENY", + "policies": { + "consul-intentions-layer4": { + "permissions": [ + { + "any": true + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$" + } + } + } + }, + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + }, + { + "and_ids": { + "ids": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/[^/]+$" + } + } + } + }, + { + "not_id": { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + }, + { + "not_id": { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/unsafe$" + } + } + } + } + }, + { + "not_id": { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$" + } + } + } + } + } + ] + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-allow-kitchen-sink.golden b/agent/xds/testdata/rbac/default-allow-kitchen-sink.golden index 7d2fd8ef6..f93600306 100644 --- a/agent/xds/testdata/rbac/default-allow-kitchen-sink.golden +++ b/agent/xds/testdata/rbac/default-allow-kitchen-sink.golden @@ -4,7 +4,7 @@ "rules": { "action": "DENY", "policies": { - "consul-intentions": { + "consul-intentions-layer4": { "permissions": [ { "any": true @@ -17,7 +17,7 @@ "safe_regex": { "google_re2": { }, - "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$" } } } @@ -28,7 +28,7 @@ "safe_regex": { "google_re2": { }, - "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$" + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" } } } @@ -47,6 +47,19 @@ } } }, + { + "not_id": { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + }, { "not_id": { "authenticated": { @@ -59,6 +72,19 @@ } } } + }, + { + "not_id": { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$" + } + } + } + } } ] } diff --git a/agent/xds/testdata/rbac/default-allow-one-deny--httpfilter.golden b/agent/xds/testdata/rbac/default-allow-one-deny--httpfilter.golden new file mode 100644 index 000000000..7f5ec1448 --- /dev/null +++ b/agent/xds/testdata/rbac/default-allow-one-deny--httpfilter.golden @@ -0,0 +1,30 @@ +{ + "name": "envoy.filters.http.rbac", + "config": { + "rules": { + "action": "DENY", + "policies": { + "consul-intentions-layer4": { + "permissions": [ + { + "any": true + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-allow-one-deny.golden b/agent/xds/testdata/rbac/default-allow-one-deny.golden index 7c04026c9..619f65122 100644 --- a/agent/xds/testdata/rbac/default-allow-one-deny.golden +++ b/agent/xds/testdata/rbac/default-allow-one-deny.golden @@ -4,7 +4,7 @@ "rules": { "action": "DENY", "policies": { - "consul-intentions": { + "consul-intentions-layer4": { "permissions": [ { "any": true diff --git a/agent/xds/testdata/rbac/default-allow-service-wildcard-deny--httpfilter.golden b/agent/xds/testdata/rbac/default-allow-service-wildcard-deny--httpfilter.golden new file mode 100644 index 000000000..06cb22397 --- /dev/null +++ b/agent/xds/testdata/rbac/default-allow-service-wildcard-deny--httpfilter.golden @@ -0,0 +1,30 @@ +{ + "name": "envoy.filters.http.rbac", + "config": { + "rules": { + "action": "DENY", + "policies": { + "consul-intentions-layer4": { + "permissions": [ + { + "any": true + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/[^/]+$" + } + } + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-allow-service-wildcard-deny.golden b/agent/xds/testdata/rbac/default-allow-service-wildcard-deny.golden index 7780f6c20..f22391bfc 100644 --- a/agent/xds/testdata/rbac/default-allow-service-wildcard-deny.golden +++ b/agent/xds/testdata/rbac/default-allow-service-wildcard-deny.golden @@ -4,7 +4,7 @@ "rules": { "action": "DENY", "policies": { - "consul-intentions": { + "consul-intentions-layer4": { "permissions": [ { "any": true diff --git a/agent/xds/testdata/rbac/default-allow-single-intention-with-kitchen-sink-perms--httpfilter.golden b/agent/xds/testdata/rbac/default-allow-single-intention-with-kitchen-sink-perms--httpfilter.golden new file mode 100644 index 000000000..2fa0fbd80 --- /dev/null +++ b/agent/xds/testdata/rbac/default-allow-single-intention-with-kitchen-sink-perms--httpfilter.golden @@ -0,0 +1,232 @@ +{ + "name": "envoy.filters.http.rbac", + "config": { + "rules": { + "action": "DENY", + "policies": { + "consul-intentions-layer7-0": { + "permissions": [ + { + "and_rules": { + "rules": [ + { + "url_path": { + "path": { + "prefix": "/v1" + } + } + }, + { + "not_rule": { + "url_path": { + "path": { + "exact": "/v1/secret" + } + } + } + } + ] + } + }, + { + "and_rules": { + "rules": [ + { + "and_rules": { + "rules": [ + { + "url_path": { + "path": { + "safe_regex": { + "google_re2": { + }, + "regex": "/v[123]" + } + } + } + }, + { + "header": { + "name": ":method", + "safe_regex_match": { + "google_re2": { + }, + "regex": "GET|HEAD|OPTIONS" + } + } + } + ] + } + }, + { + "not_rule": { + "url_path": { + "path": { + "prefix": "/v1" + } + } + } + }, + { + "not_rule": { + "url_path": { + "path": { + "exact": "/v1/secret" + } + } + } + } + ] + } + }, + { + "and_rules": { + "rules": [ + { + "and_rules": { + "rules": [ + { + "header": { + "name": "x-foo", + "present_match": true + } + }, + { + "header": { + "exact_match": "xyz", + "name": "x-bar" + } + }, + { + "header": { + "name": "x-dib", + "prefix_match": "gaz" + } + }, + { + "header": { + "name": "x-gir", + "suffix_match": "zim" + } + }, + { + "header": { + "name": "x-zim", + "safe_regex_match": { + "google_re2": { + }, + "regex": "gi[rR]" + } + } + }, + { + "header": { + "invert_match": true, + "name": "z-foo", + "present_match": true + } + }, + { + "header": { + "exact_match": "xyz", + "invert_match": true, + "name": "z-bar" + } + }, + { + "header": { + "invert_match": true, + "name": "z-dib", + "prefix_match": "gaz" + } + }, + { + "header": { + "invert_match": true, + "name": "z-gir", + "suffix_match": "zim" + } + }, + { + "header": { + "invert_match": true, + "name": "z-zim", + "safe_regex_match": { + "google_re2": { + }, + "regex": "gi[rR]" + } + } + } + ] + } + }, + { + "not_rule": { + "and_rules": { + "rules": [ + { + "url_path": { + "path": { + "safe_regex": { + "google_re2": { + }, + "regex": "/v[123]" + } + } + } + }, + { + "header": { + "name": ":method", + "safe_regex_match": { + "google_re2": { + }, + "regex": "GET|HEAD|OPTIONS" + } + } + } + ] + } + } + }, + { + "not_rule": { + "url_path": { + "path": { + "prefix": "/v1" + } + } + } + }, + { + "not_rule": { + "url_path": { + "path": { + "exact": "/v1/secret" + } + } + } + } + ] + } + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-allow-single-intention-with-kitchen-sink-perms.golden b/agent/xds/testdata/rbac/default-allow-single-intention-with-kitchen-sink-perms.golden new file mode 100644 index 000000000..619f65122 --- /dev/null +++ b/agent/xds/testdata/rbac/default-allow-single-intention-with-kitchen-sink-perms.golden @@ -0,0 +1,31 @@ +{ + "name": "envoy.filters.network.rbac", + "config": { + "rules": { + "action": "DENY", + "policies": { + "consul-intentions-layer4": { + "permissions": [ + { + "any": true + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + ] + } + } + }, + "stat_prefix": "connect_authz" + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-allow-two-path-deny-and-path-allow--httpfilter.golden b/agent/xds/testdata/rbac/default-allow-two-path-deny-and-path-allow--httpfilter.golden new file mode 100644 index 000000000..f62088721 --- /dev/null +++ b/agent/xds/testdata/rbac/default-allow-two-path-deny-and-path-allow--httpfilter.golden @@ -0,0 +1,56 @@ +{ + "name": "envoy.filters.http.rbac", + "config": { + "rules": { + "action": "DENY", + "policies": { + "consul-intentions-layer7-0": { + "permissions": [ + { + "url_path": { + "path": { + "exact": "/v1/secret" + } + } + }, + { + "and_rules": { + "rules": [ + { + "url_path": { + "path": { + "exact": "/v1/admin" + } + } + }, + { + "not_rule": { + "url_path": { + "path": { + "exact": "/v1/secret" + } + } + } + } + ] + } + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-allow-two-path-deny-and-path-allow.golden b/agent/xds/testdata/rbac/default-allow-two-path-deny-and-path-allow.golden new file mode 100644 index 000000000..619f65122 --- /dev/null +++ b/agent/xds/testdata/rbac/default-allow-two-path-deny-and-path-allow.golden @@ -0,0 +1,31 @@ +{ + "name": "envoy.filters.network.rbac", + "config": { + "rules": { + "action": "DENY", + "policies": { + "consul-intentions-layer4": { + "permissions": [ + { + "any": true + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + ] + } + } + }, + "stat_prefix": "connect_authz" + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-deny-allow-deny--httpfilter.golden b/agent/xds/testdata/rbac/default-deny-allow-deny--httpfilter.golden new file mode 100644 index 000000000..4da75ff1b --- /dev/null +++ b/agent/xds/testdata/rbac/default-deny-allow-deny--httpfilter.golden @@ -0,0 +1,48 @@ +{ + "name": "envoy.filters.http.rbac", + "config": { + "rules": { + "policies": { + "consul-intentions-layer4": { + "permissions": [ + { + "any": true + } + ], + "principals": [ + { + "and_ids": { + "ids": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/[^/]+$" + } + } + } + }, + { + "not_id": { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + } + ] + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-deny-allow-deny.golden b/agent/xds/testdata/rbac/default-deny-allow-deny.golden index 1126b28ee..8130e4a97 100644 --- a/agent/xds/testdata/rbac/default-deny-allow-deny.golden +++ b/agent/xds/testdata/rbac/default-deny-allow-deny.golden @@ -3,7 +3,7 @@ "config": { "rules": { "policies": { - "consul-intentions": { + "consul-intentions-layer4": { "permissions": [ { "any": true diff --git a/agent/xds/testdata/rbac/default-deny-kitchen-sink--httpfilter.golden b/agent/xds/testdata/rbac/default-deny-kitchen-sink--httpfilter.golden new file mode 100644 index 000000000..30d2b663b --- /dev/null +++ b/agent/xds/testdata/rbac/default-deny-kitchen-sink--httpfilter.golden @@ -0,0 +1,96 @@ +{ + "name": "envoy.filters.http.rbac", + "config": { + "rules": { + "policies": { + "consul-intentions-layer4": { + "permissions": [ + { + "any": true + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$" + } + } + } + }, + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + }, + { + "and_ids": { + "ids": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/[^/]+$" + } + } + } + }, + { + "not_id": { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + }, + { + "not_id": { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/unsafe$" + } + } + } + } + }, + { + "not_id": { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$" + } + } + } + } + } + ] + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-deny-kitchen-sink.golden b/agent/xds/testdata/rbac/default-deny-kitchen-sink.golden index 1f5e1f134..73fbd44be 100644 --- a/agent/xds/testdata/rbac/default-deny-kitchen-sink.golden +++ b/agent/xds/testdata/rbac/default-deny-kitchen-sink.golden @@ -3,7 +3,7 @@ "config": { "rules": { "policies": { - "consul-intentions": { + "consul-intentions-layer4": { "permissions": [ { "any": true @@ -16,7 +16,7 @@ "safe_regex": { "google_re2": { }, - "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$" } } } @@ -27,7 +27,7 @@ "safe_regex": { "google_re2": { }, - "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$" + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" } } } @@ -46,6 +46,19 @@ } } }, + { + "not_id": { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + }, { "not_id": { "authenticated": { @@ -58,6 +71,19 @@ } } } + }, + { + "not_id": { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$" + } + } + } + } } ] } diff --git a/agent/xds/testdata/rbac/default-deny-mixed-precedence--httpfilter.golden b/agent/xds/testdata/rbac/default-deny-mixed-precedence--httpfilter.golden new file mode 100644 index 000000000..7041bb5c8 --- /dev/null +++ b/agent/xds/testdata/rbac/default-deny-mixed-precedence--httpfilter.golden @@ -0,0 +1,29 @@ +{ + "name": "envoy.filters.http.rbac", + "config": { + "rules": { + "policies": { + "consul-intentions-layer4": { + "permissions": [ + { + "any": true + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-deny-mixed-precedence.golden b/agent/xds/testdata/rbac/default-deny-mixed-precedence.golden index 8b879b865..61fa1647e 100644 --- a/agent/xds/testdata/rbac/default-deny-mixed-precedence.golden +++ b/agent/xds/testdata/rbac/default-deny-mixed-precedence.golden @@ -3,7 +3,7 @@ "config": { "rules": { "policies": { - "consul-intentions": { + "consul-intentions-layer4": { "permissions": [ { "any": true diff --git a/agent/xds/testdata/rbac/default-deny-one-allow--httpfilter.golden b/agent/xds/testdata/rbac/default-deny-one-allow--httpfilter.golden new file mode 100644 index 000000000..7041bb5c8 --- /dev/null +++ b/agent/xds/testdata/rbac/default-deny-one-allow--httpfilter.golden @@ -0,0 +1,29 @@ +{ + "name": "envoy.filters.http.rbac", + "config": { + "rules": { + "policies": { + "consul-intentions-layer4": { + "permissions": [ + { + "any": true + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-deny-one-allow.golden b/agent/xds/testdata/rbac/default-deny-one-allow.golden index 8b879b865..61fa1647e 100644 --- a/agent/xds/testdata/rbac/default-deny-one-allow.golden +++ b/agent/xds/testdata/rbac/default-deny-one-allow.golden @@ -3,7 +3,7 @@ "config": { "rules": { "policies": { - "consul-intentions": { + "consul-intentions-layer4": { "permissions": [ { "any": true diff --git a/agent/xds/testdata/rbac/default-deny-service-wildcard-allow--httpfilter.golden b/agent/xds/testdata/rbac/default-deny-service-wildcard-allow--httpfilter.golden new file mode 100644 index 000000000..505c80d9f --- /dev/null +++ b/agent/xds/testdata/rbac/default-deny-service-wildcard-allow--httpfilter.golden @@ -0,0 +1,29 @@ +{ + "name": "envoy.filters.http.rbac", + "config": { + "rules": { + "policies": { + "consul-intentions-layer4": { + "permissions": [ + { + "any": true + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/[^/]+$" + } + } + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-deny-service-wildcard-allow.golden b/agent/xds/testdata/rbac/default-deny-service-wildcard-allow.golden index e2c36db99..f4a597741 100644 --- a/agent/xds/testdata/rbac/default-deny-service-wildcard-allow.golden +++ b/agent/xds/testdata/rbac/default-deny-service-wildcard-allow.golden @@ -3,7 +3,7 @@ "config": { "rules": { "policies": { - "consul-intentions": { + "consul-intentions-layer4": { "permissions": [ { "any": true diff --git a/agent/xds/testdata/rbac/default-deny-single-intention-with-kitchen-sink-perms--httpfilter.golden b/agent/xds/testdata/rbac/default-deny-single-intention-with-kitchen-sink-perms--httpfilter.golden new file mode 100644 index 000000000..69dcabc92 --- /dev/null +++ b/agent/xds/testdata/rbac/default-deny-single-intention-with-kitchen-sink-perms--httpfilter.golden @@ -0,0 +1,231 @@ +{ + "name": "envoy.filters.http.rbac", + "config": { + "rules": { + "policies": { + "consul-intentions-layer7-0": { + "permissions": [ + { + "and_rules": { + "rules": [ + { + "url_path": { + "path": { + "prefix": "/v1" + } + } + }, + { + "not_rule": { + "url_path": { + "path": { + "exact": "/v1/secret" + } + } + } + } + ] + } + }, + { + "and_rules": { + "rules": [ + { + "and_rules": { + "rules": [ + { + "url_path": { + "path": { + "safe_regex": { + "google_re2": { + }, + "regex": "/v[123]" + } + } + } + }, + { + "header": { + "name": ":method", + "safe_regex_match": { + "google_re2": { + }, + "regex": "GET|HEAD|OPTIONS" + } + } + } + ] + } + }, + { + "not_rule": { + "url_path": { + "path": { + "prefix": "/v1" + } + } + } + }, + { + "not_rule": { + "url_path": { + "path": { + "exact": "/v1/secret" + } + } + } + } + ] + } + }, + { + "and_rules": { + "rules": [ + { + "and_rules": { + "rules": [ + { + "header": { + "name": "x-foo", + "present_match": true + } + }, + { + "header": { + "exact_match": "xyz", + "name": "x-bar" + } + }, + { + "header": { + "name": "x-dib", + "prefix_match": "gaz" + } + }, + { + "header": { + "name": "x-gir", + "suffix_match": "zim" + } + }, + { + "header": { + "name": "x-zim", + "safe_regex_match": { + "google_re2": { + }, + "regex": "gi[rR]" + } + } + }, + { + "header": { + "invert_match": true, + "name": "z-foo", + "present_match": true + } + }, + { + "header": { + "exact_match": "xyz", + "invert_match": true, + "name": "z-bar" + } + }, + { + "header": { + "invert_match": true, + "name": "z-dib", + "prefix_match": "gaz" + } + }, + { + "header": { + "invert_match": true, + "name": "z-gir", + "suffix_match": "zim" + } + }, + { + "header": { + "invert_match": true, + "name": "z-zim", + "safe_regex_match": { + "google_re2": { + }, + "regex": "gi[rR]" + } + } + } + ] + } + }, + { + "not_rule": { + "and_rules": { + "rules": [ + { + "url_path": { + "path": { + "safe_regex": { + "google_re2": { + }, + "regex": "/v[123]" + } + } + } + }, + { + "header": { + "name": ":method", + "safe_regex_match": { + "google_re2": { + }, + "regex": "GET|HEAD|OPTIONS" + } + } + } + ] + } + } + }, + { + "not_rule": { + "url_path": { + "path": { + "prefix": "/v1" + } + } + } + }, + { + "not_rule": { + "url_path": { + "path": { + "exact": "/v1/secret" + } + } + } + } + ] + } + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-deny-single-intention-with-kitchen-sink-perms.golden b/agent/xds/testdata/rbac/default-deny-single-intention-with-kitchen-sink-perms.golden new file mode 100644 index 000000000..ae6aeaee7 --- /dev/null +++ b/agent/xds/testdata/rbac/default-deny-single-intention-with-kitchen-sink-perms.golden @@ -0,0 +1,8 @@ +{ + "name": "envoy.filters.network.rbac", + "config": { + "rules": { + }, + "stat_prefix": "connect_authz" + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-deny-two-path-deny-and-path-allow--httpfilter.golden b/agent/xds/testdata/rbac/default-deny-two-path-deny-and-path-allow--httpfilter.golden new file mode 100644 index 000000000..f3292aa10 --- /dev/null +++ b/agent/xds/testdata/rbac/default-deny-two-path-deny-and-path-allow--httpfilter.golden @@ -0,0 +1,57 @@ +{ + "name": "envoy.filters.http.rbac", + "config": { + "rules": { + "policies": { + "consul-intentions-layer7-0": { + "permissions": [ + { + "and_rules": { + "rules": [ + { + "url_path": { + "path": { + "prefix": "/" + } + } + }, + { + "not_rule": { + "url_path": { + "path": { + "exact": "/v1/admin" + } + } + } + }, + { + "not_rule": { + "url_path": { + "path": { + "exact": "/v1/secret" + } + } + } + } + ] + } + } + ], + "principals": [ + { + "authenticated": { + "principal_name": { + "safe_regex": { + "google_re2": { + }, + "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" + } + } + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/agent/xds/testdata/rbac/default-deny-two-path-deny-and-path-allow.golden b/agent/xds/testdata/rbac/default-deny-two-path-deny-and-path-allow.golden new file mode 100644 index 000000000..ae6aeaee7 --- /dev/null +++ b/agent/xds/testdata/rbac/default-deny-two-path-deny-and-path-allow.golden @@ -0,0 +1,8 @@ +{ + "name": "envoy.filters.network.rbac", + "config": { + "rules": { + }, + "stat_prefix": "connect_authz" + } +} \ No newline at end of file diff --git a/api/config_entry_intentions.go b/api/config_entry_intentions.go index 9b5c9a8f0..187a42506 100644 --- a/api/config_entry_intentions.go +++ b/api/config_entry_intentions.go @@ -17,8 +17,9 @@ type ServiceIntentionsConfigEntry struct { type SourceIntention struct { Name string - Namespace string `json:",omitempty"` - Action IntentionAction + Namespace string `json:",omitempty"` + Action IntentionAction `json:",omitempty"` + Permissions []*IntentionPermission `json:",omitempty"` Precedence int Type IntentionSourceType Description string `json:",omitempty"` @@ -52,3 +53,28 @@ func (e *ServiceIntentionsConfigEntry) GetCreateIndex() uint64 { func (e *ServiceIntentionsConfigEntry) GetModifyIndex() uint64 { return e.ModifyIndex } + +type IntentionPermission struct { + Action IntentionAction + HTTP *IntentionHTTPPermission `json:",omitempty"` +} + +type IntentionHTTPPermission struct { + PathExact string `json:",omitempty" alias:"path_exact"` + PathPrefix string `json:",omitempty" alias:"path_prefix"` + PathRegex string `json:",omitempty" alias:"path_regex"` + + Header []IntentionHTTPHeaderPermission `json:",omitempty"` + + Methods []string `json:",omitempty"` +} + +type IntentionHTTPHeaderPermission struct { + Name string + Present bool `json:",omitempty"` + Exact string `json:",omitempty"` + Prefix string `json:",omitempty"` + Suffix string `json:",omitempty"` + Regex string `json:",omitempty"` + Invert bool `json:",omitempty"` +} diff --git a/api/config_entry_test.go b/api/config_entry_test.go index 631b27097..334c97252 100644 --- a/api/config_entry_test.go +++ b/api/config_entry_test.go @@ -900,6 +900,168 @@ func TestDecodeConfigEntry(t *testing.T) { }, }, }, + { + name: "service-intentions: kitchen sink", + body: ` + { + "Kind": "service-intentions", + "Name": "web", + "Meta" : { + "foo": "bar", + "gir": "zim" + }, + "Sources": [ + { + "Name": "foo", + "Action": "deny", + "Type": "consul", + "Description": "foo desc" + }, + { + "Name": "bar", + "Action": "allow", + "Description": "bar desc" + }, + { + "Name": "l7", + "Permissions": [ + { + "Action": "deny", + "HTTP": { + "PathExact": "/admin", + "Header": [ + { + "Name": "hdr-present", + "Present": true + }, + { + "Name": "hdr-exact", + "Exact": "exact" + }, + { + "Name": "hdr-prefix", + "Prefix": "prefix" + }, + { + "Name": "hdr-suffix", + "Suffix": "suffix" + }, + { + "Name": "hdr-regex", + "Regex": "regex" + }, + { + "Name": "hdr-absent", + "Present": true, + "Invert": true + } + ] + } + }, + { + "Action": "allow", + "HTTP": { + "PathPrefix": "/v3/" + } + }, + { + "Action": "allow", + "HTTP": { + "PathRegex": "/v[12]/.*", + "Methods": [ + "GET", + "POST" + ] + } + } + ] + }, + { + "Name": "*", + "Action": "deny", + "Description": "wild desc" + } + ] + } + `, + expect: &ServiceIntentionsConfigEntry{ + Kind: "service-intentions", + Name: "web", + Meta: map[string]string{ + "foo": "bar", + "gir": "zim", + }, + Sources: []*SourceIntention{ + { + Name: "foo", + Action: "deny", + Type: "consul", + Description: "foo desc", + }, + { + Name: "bar", + Action: "allow", + Description: "bar desc", + }, + { + Name: "l7", + Permissions: []*IntentionPermission{ + { + Action: "deny", + HTTP: &IntentionHTTPPermission{ + PathExact: "/admin", + Header: []IntentionHTTPHeaderPermission{ + { + Name: "hdr-present", + Present: true, + }, + { + Name: "hdr-exact", + Exact: "exact", + }, + { + Name: "hdr-prefix", + Prefix: "prefix", + }, + { + Name: "hdr-suffix", + Suffix: "suffix", + }, + { + Name: "hdr-regex", + Regex: "regex", + }, + { + Name: "hdr-absent", + Present: true, + Invert: true, + }, + }, + }, + }, + { + Action: "allow", + HTTP: &IntentionHTTPPermission{ + PathPrefix: "/v3/", + }, + }, + { + Action: "allow", + HTTP: &IntentionHTTPPermission{ + PathRegex: "/v[12]/.*", + Methods: []string{"GET", "POST"}, + }, + }, + }, + }, + { + Name: "*", + Action: "deny", + Description: "wild desc", + }, + }, + }, + }, } { tc := tc diff --git a/api/connect_intention.go b/api/connect_intention.go index 328477d81..26fb6cc4b 100644 --- a/api/connect_intention.go +++ b/api/connect_intention.go @@ -34,7 +34,14 @@ type Intention struct { SourceType IntentionSourceType // Action is whether this is an allowlist or denylist intention. - Action IntentionAction + Action IntentionAction `json:",omitempty"` + + // Permissions is the list of additional L7 attributes that extend the + // intention definition. + // + // NOTE: This field is not editable unless editing the underlying + // service-intentions config entry directly. + Permissions []*IntentionPermission `json:",omitempty"` // DefaultAddr is not used. // Deprecated: DefaultAddr is not used and may be removed in a future version. @@ -69,10 +76,20 @@ type Intention struct { // String returns human-friendly output describing ths intention. func (i *Intention) String() string { + var detail string + switch n := len(i.Permissions); n { + case 0: + detail = string(i.Action) + case 1: + detail = "1 permission" + default: + detail = fmt.Sprintf("%d permissions", len(i.Permissions)) + } + return fmt.Sprintf("%s => %s (%s)", i.SourceString(), i.DestinationString(), - i.Action) + detail) } // SourceString returns the namespace/name format for the source, or diff --git a/command/config/write/config_write_test.go b/command/config/write/config_write_test.go index 85dce21bc..db9be331e 100644 --- a/command/config/write/config_write_test.go +++ b/command/config/write/config_write_test.go @@ -1917,8 +1917,8 @@ func TestParseConfigEntry(t *testing.T) { kind = "service-intentions" name = "web" meta { - "foo" = "bar" - "gir" = "zim" + "foo" = "bar" + "gir" = "zim" } sources = [ { @@ -1931,6 +1931,57 @@ func TestParseConfigEntry(t *testing.T) { name = "bar" action = "allow" description = "bar desc" + }, + { + name = "l7" + permissions = [ + { + action = "deny" + http { + path_exact = "/admin" + header = [ + { + name = "hdr-present" + present = true + }, + { + name = "hdr-exact" + exact = "exact" + }, + { + name = "hdr-prefix" + prefix = "prefix" + }, + { + name = "hdr-suffix" + suffix = "suffix" + }, + { + name = "hdr-regex" + regex = "regex" + }, + { + name = "hdr-absent" + present = true + invert = true + } + ] + } + }, + { + action = "allow" + http { + path_prefix = "/v3/" + } + }, + { + action = "allow" + http { + path_regex = "/v[12]/.*" + methods = ["GET", "POST"] + } + } + ] } ] sources { @@ -1957,6 +2008,57 @@ func TestParseConfigEntry(t *testing.T) { Name = "bar" Action = "allow" Description = "bar desc" + }, + { + Name = "l7" + Permissions = [ + { + Action = "deny" + HTTP { + PathExact = "/admin" + Header = [ + { + Name = "hdr-present" + Present = true + }, + { + Name = "hdr-exact" + Exact = "exact" + }, + { + Name = "hdr-prefix" + Prefix = "prefix" + }, + { + Name = "hdr-suffix" + Suffix = "suffix" + }, + { + Name = "hdr-regex" + Regex = "regex" + }, + { + Name = "hdr-absent" + Present = true + Invert = true + } + ] + } + }, + { + Action = "allow" + HTTP { + PathPrefix = "/v3/" + } + }, + { + Action = "allow" + HTTP { + PathRegex = "/v[12]/.*" + Methods = ["GET", "POST"] + } + } + ] } ] Sources { @@ -1969,7 +2071,7 @@ func TestParseConfigEntry(t *testing.T) { { "kind": "service-intentions", "name": "web", - "meta" : { + "meta": { "foo": "bar", "gir": "zim" }, @@ -1985,6 +2087,60 @@ func TestParseConfigEntry(t *testing.T) { "action": "allow", "description": "bar desc" }, + { + "name": "l7", + "permissions": [ + { + "action": "deny", + "http": { + "path_exact": "/admin", + "header": [ + { + "name": "hdr-present", + "present": true + }, + { + "name": "hdr-exact", + "exact": "exact" + }, + { + "name": "hdr-prefix", + "prefix": "prefix" + }, + { + "name": "hdr-suffix", + "suffix": "suffix" + }, + { + "name": "hdr-regex", + "regex": "regex" + }, + { + "name": "hdr-absent", + "present": true, + "invert": true + } + ] + } + }, + { + "action": "allow", + "http": { + "path_prefix": "/v3/" + } + }, + { + "action": "allow", + "http": { + "path_regex": "/v[12]/.*", + "methods": [ + "GET", + "POST" + ] + } + } + ] + }, { "name": "*", "action": "deny", @@ -2013,6 +2169,60 @@ func TestParseConfigEntry(t *testing.T) { "Action": "allow", "Description": "bar desc" }, + { + "Name": "l7", + "Permissions": [ + { + "Action": "deny", + "HTTP": { + "PathExact": "/admin", + "Header": [ + { + "Name": "hdr-present", + "Present": true + }, + { + "Name": "hdr-exact", + "Exact": "exact" + }, + { + "Name": "hdr-prefix", + "Prefix": "prefix" + }, + { + "Name": "hdr-suffix", + "Suffix": "suffix" + }, + { + "Name": "hdr-regex", + "Regex": "regex" + }, + { + "Name": "hdr-absent", + "Present": true, + "Invert": true + } + ] + } + }, + { + "Action": "allow", + "HTTP": { + "PathPrefix": "/v3/" + } + }, + { + "Action": "allow", + "HTTP": { + "PathRegex": "/v[12]/.*", + "Methods": [ + "GET", + "POST" + ] + } + } + ] + }, { "Name": "*", "Action": "deny", @@ -2040,6 +2250,57 @@ func TestParseConfigEntry(t *testing.T) { Action: "allow", Description: "bar desc", }, + { + Name: "l7", + Permissions: []*api.IntentionPermission{ + { + Action: "deny", + HTTP: &api.IntentionHTTPPermission{ + PathExact: "/admin", + Header: []api.IntentionHTTPHeaderPermission{ + { + Name: "hdr-present", + Present: true, + }, + { + Name: "hdr-exact", + Exact: "exact", + }, + { + Name: "hdr-prefix", + Prefix: "prefix", + }, + { + Name: "hdr-suffix", + Suffix: "suffix", + }, + { + Name: "hdr-regex", + Regex: "regex", + }, + { + Name: "hdr-absent", + Present: true, + Invert: true, + }, + }, + }, + }, + { + Action: "allow", + HTTP: &api.IntentionHTTPPermission{ + PathPrefix: "/v3/", + }, + }, + { + Action: "allow", + HTTP: &api.IntentionHTTPPermission{ + PathRegex: "/v[12]/.*", + Methods: []string{"GET", "POST"}, + }, + }, + }, + }, { Name: "*", Action: "deny", diff --git a/command/intention/create/create.go b/command/intention/create/create.go index 61d113ecb..37faeae44 100644 --- a/command/intention/create/create.go +++ b/command/intention/create/create.go @@ -177,24 +177,36 @@ func (c *cmd) ixnsFromArgs(args []string) ([]*api.Intention, error) { func (c *cmd) ixnsFromFiles(args []string) ([]*api.Intention, error) { var result []*api.Intention for _, path := range args { - f, err := os.Open(path) + ixn, err := c.ixnFromFile(path) if err != nil { return nil, err } - var ixn api.Intention - err = json.NewDecoder(f).Decode(&ixn) - f.Close() - if err != nil { - return nil, err - } - - result = append(result, &ixn) + result = append(result, ixn) } return result, nil } +func (c *cmd) ixnFromFile(path string) (*api.Intention, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var ixn api.Intention + if err := json.NewDecoder(f).Decode(&ixn); err != nil { + return nil, err + } + + if len(ixn.Permissions) > 0 { + return nil, fmt.Errorf("cannot create L7 intention from file %q using this CLI; use 'consul config write' instead", path) + } + + return &ixn, nil +} + // ixnAction returns the api.IntentionAction based on the flag set. func (c *cmd) ixnAction() api.IntentionAction { if c.flagAllow { diff --git a/command/intention/create/create_test.go b/command/intention/create/create_test.go index 0c05dc5e2..cd2ee28e2 100644 --- a/command/intention/create/create_test.go +++ b/command/intention/create/create_test.go @@ -174,6 +174,47 @@ func TestIntentionCreate_File(t *testing.T) { require.Equal(api.IntentionActionAllow, ixns[0].Action) } +func TestIntentionCreate_File_L7_fails(t *testing.T) { + t.Parallel() + + require := require.New(t) + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() + + testrpc.WaitForTestAgent(t, a.RPC, "dc1") + + ui := cli.NewMockUi() + c := New(ui) + + contents := ` +{ + "SourceName": "foo", + "DestinationName": "bar", + "Permissions": [ + { + "Action": "allow", + "HTTP": { + "PathExact": "/foo" + } + } + ] +} + ` + f := testutil.TempFile(t, "intention-create-command-file") + if _, err := f.WriteString(contents); err != nil { + t.Fatalf("err: %#v", err) + } + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-file", + f.Name(), + } + + require.Equal(1, c.Run(args), ui.ErrorWriter.String()) + require.Contains(ui.ErrorWriter.String(), "cannot create L7 intention from file") +} + func TestIntentionCreate_FileNoExist(t *testing.T) { t.Parallel() diff --git a/command/intention/delete/delete_test.go b/command/intention/delete/delete_test.go index c82594a56..a1fa15b4f 100644 --- a/command/intention/delete/delete_test.go +++ b/command/intention/delete/delete_test.go @@ -85,6 +85,28 @@ func TestIntentionDelete(t *testing.T) { }, nil) require.NoError(t, err) + // Ensure "api" is L7 + _, _, err = client.ConfigEntries().Set(&api.ServiceConfigEntry{ + Kind: api.ServiceDefaults, + Name: "api", + Protocol: "http", + }, nil) + require.NoError(t, err) + + _, err = client.Connect().IntentionUpsert(&api.Intention{ + SourceName: "web", + DestinationName: "api", + Permissions: []*api.IntentionPermission{ + { + Action: api.IntentionActionAllow, + HTTP: &api.IntentionHTTPPermission{ + PathExact: "/foo", + }, + }, + }, + }, nil) + require.NoError(t, err) + t.Run("l4 intention", func(t *testing.T) { t.Run("one arg", func(t *testing.T) { ui := cli.NewMockUi() @@ -110,6 +132,20 @@ func TestIntentionDelete(t *testing.T) { }) }) + t.Run("l7 intention", func(t *testing.T) { + t.Run("two args", func(t *testing.T) { + ui := cli.NewMockUi() + c := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "web", "api", + } + require.Equal(t, 0, c.Run(args), ui.ErrorWriter.String()) + require.Contains(t, ui.OutputWriter.String(), "deleted") + }) + }) + // They should all be gone. { ixns, _, err := client.Connect().Intentions(nil) diff --git a/command/intention/get/get.go b/command/intention/get/get.go index e0c7ef1fa..dbf693713 100644 --- a/command/intention/get/get.go +++ b/command/intention/get/get.go @@ -1,6 +1,7 @@ package get import ( + "encoding/json" "flag" "fmt" "io" @@ -60,7 +61,9 @@ func (c *cmd) Run(args []string) int { data := []string{ fmt.Sprintf("Source:\x1f%s", ixn.SourceString()), fmt.Sprintf("Destination:\x1f%s", ixn.DestinationString()), - fmt.Sprintf("Action:\x1f%s", ixn.Action), + } + if ixn.Action != "" { + data = append(data, fmt.Sprintf("Action:\x1f%s", ixn.Action)) } if ixn.ID != "" { data = append(data, fmt.Sprintf("ID:\x1f%s", ixn.ID)) @@ -84,6 +87,14 @@ func (c *cmd) Run(args []string) int { c.UI.Output(columnize.Format(data, &columnize.Config{Delim: string([]byte{0x1f})})) + if len(ixn.Permissions) > 0 { + b, err := json.MarshalIndent(ixn.Permissions, "", " ") + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + c.UI.Output("Permissions:\n" + string(b)) + } return 0 } diff --git a/command/intention/helpers_test.go b/command/intention/helpers_test.go index e0df8ab22..3ca5bfd10 100644 --- a/command/intention/helpers_test.go +++ b/command/intention/helpers_test.go @@ -28,6 +28,28 @@ func TestGetFromArgs(t *testing.T) { }, nil) require.NoError(t, err) + // Ensure "y" is L7 + _, _, err = client.ConfigEntries().Set(&api.ServiceConfigEntry{ + Kind: api.ServiceDefaults, + Name: "y", + Protocol: "http", + }, nil) + require.NoError(t, err) + + _, err = client.Connect().IntentionUpsert(&api.Intention{ + SourceName: "x", + DestinationName: "y", + Permissions: []*api.IntentionPermission{ + { + Action: api.IntentionActionAllow, + HTTP: &api.IntentionHTTPPermission{ + PathExact: "/foo", + }, + }, + }, + }, nil) + require.NoError(t, err) + t.Run("l4 intention", func(t *testing.T) { t.Run("one arg", func(t *testing.T) { ixn, err := GetFromArgs(client, []string{id0}) @@ -36,6 +58,7 @@ func TestGetFromArgs(t *testing.T) { require.Equal(t, "a", ixn.SourceName) require.Equal(t, "b", ixn.DestinationName) require.Equal(t, api.IntentionActionAllow, ixn.Action) + require.Empty(t, ixn.Permissions) }) t.Run("two args", func(t *testing.T) { ixn, err := GetFromArgs(client, []string{"a", "b"}) @@ -44,6 +67,24 @@ func TestGetFromArgs(t *testing.T) { require.Equal(t, "a", ixn.SourceName) require.Equal(t, "b", ixn.DestinationName) require.Equal(t, api.IntentionActionAllow, ixn.Action) + require.Empty(t, ixn.Permissions) + }) + }) + + t.Run("l7 intention", func(t *testing.T) { + t.Run("two args", func(t *testing.T) { + ixn, err := GetFromArgs(client, []string{"x", "y"}) + require.NoError(t, err) + require.Empty(t, ixn.ID) + require.Equal(t, "x", ixn.SourceName) + require.Equal(t, "y", ixn.DestinationName) + require.Empty(t, ixn.Action) + require.Equal(t, []*api.IntentionPermission{{ + Action: api.IntentionActionAllow, + HTTP: &api.IntentionHTTPPermission{ + PathExact: "/foo", + }, + }}, ixn.Permissions) }) }) diff --git a/test/integration/connect/envoy/case-l7-intentions/capture.sh b/test/integration/connect/envoy/case-l7-intentions/capture.sh new file mode 100644 index 000000000..ae4715220 --- /dev/null +++ b/test/integration/connect/envoy/case-l7-intentions/capture.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +snapshot_envoy_admin localhost:19000 s1 primary || true +snapshot_envoy_admin localhost:19001 s2 || true diff --git a/test/integration/connect/envoy/case-l7-intentions/config_entries.hcl b/test/integration/connect/envoy/case-l7-intentions/config_entries.hcl new file mode 100644 index 000000000..e16a540a7 --- /dev/null +++ b/test/integration/connect/envoy/case-l7-intentions/config_entries.hcl @@ -0,0 +1,97 @@ +enable_central_service_config = true + +acl { + default_policy = "deny" +} + +config_entries { + bootstrap { + kind = "service-defaults" + name = "s2" + protocol = "http" + } + + # TODO: test header invert + bootstrap { + kind = "service-intentions" + name = "s2" + + sources { + name = "s1" + permissions = [ + // paths + { + action = "allow" + http { path_exact = "/exact" } + }, + { + action = "allow" + http { path_prefix = "/prefix" } + }, + { + action = "allow" + http { path_regex = "/reg[ex]{2}" } + }, + // headers + { + action = "allow" + http { + path_exact = "/hdr-present" + header = [{ + name = "x-test-debug" + present = true + }] + } + }, + { + action = "allow" + http { + path_exact = "/hdr-exact" + header = [{ + name = "x-test-debug" + exact = "exact" + }] + } + }, + { + action = "allow" + http { + path_exact = "/hdr-prefix" + header = [{ + name = "x-test-debug" + prefix = "prefi" + }] + } + }, + { + action = "allow" + http { + path_exact = "/hdr-suffix" + header = [{ + name = "x-test-debug" + suffix = "uffix" + }] + } + }, + { + action = "allow" + http { + path_exact = "/hdr-regex" + header = [{ + name = "x-test-debug" + regex = "reg[ex]{2}" + }] + } + }, + // methods + { + action = "allow" + http { + path_exact = "/method-match" + methods = ["GET", "PUT"] + } + } + ] + } + } +} diff --git a/test/integration/connect/envoy/case-l7-intentions/setup.sh b/test/integration/connect/envoy/case-l7-intentions/setup.sh new file mode 100644 index 000000000..58290b374 --- /dev/null +++ b/test/integration/connect/envoy/case-l7-intentions/setup.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -euo pipefail + +# wait for bootstrap to apply config entries +wait_for_config_entry service-defaults s2 +wait_for_config_entry service-intentions s2 + +gen_envoy_bootstrap s1 19000 +gen_envoy_bootstrap s2 19001 diff --git a/test/integration/connect/envoy/case-l7-intentions/verify.bats b/test/integration/connect/envoy/case-l7-intentions/verify.bats new file mode 100644 index 000000000..4be826899 --- /dev/null +++ b/test/integration/connect/envoy/case-l7-intentions/verify.bats @@ -0,0 +1,84 @@ +#!/usr/bin/env bats + +load helpers + +@test "s1 proxy admin is up on :19000" { + retry_default curl -f -s localhost:19000/stats -o /dev/null +} + +@test "s2 proxy admin is up on :19001" { + retry_default curl -f -s localhost:19001/stats -o /dev/null +} + +@test "s1 proxy listener should be up and have right cert" { + assert_proxy_presents_cert_uri localhost:21000 s1 +} + +@test "s2 proxy listener should be up and have right cert" { + assert_proxy_presents_cert_uri localhost:21001 s2 +} + +@test "s2 proxies should be healthy" { + assert_service_has_healthy_instances s2 1 +} + +@test "s1 upstream should have healthy endpoints for s2" { + assert_upstream_has_endpoints_in_status 127.0.0.1:19000 s2.default.primary HEALTHY 1 +} + +# these all use the same context: "s1 upstream should be able to connect to s2 via upstream s2" + +@test "test exact path" { + must_pass_http_request GET localhost:5000/exact + must_fail_http_request GET localhost:5000/exact-nope +} + +@test "test prefix path" { + must_pass_http_request GET localhost:5000/prefix + must_fail_http_request GET localhost:5000/nope-prefix +} + +@test "test regex path" { + must_pass_http_request GET localhost:5000/regex + must_fail_http_request GET localhost:5000/reggex +} + +@test "test present header" { + must_pass_http_request GET localhost:5000/hdr-present anything + must_fail_http_request GET localhost:5000/hdr-present "" +} + +@test "test exact header" { + must_pass_http_request GET localhost:5000/hdr-exact exact + must_fail_http_request GET localhost:5000/hdr-exact exact-nope +} + +@test "test prefix header" { + must_pass_http_request GET localhost:5000/hdr-prefix prefix + must_fail_http_request GET localhost:5000/hdr-prefix nope-prefix +} + +@test "test suffix header" { + must_pass_http_request GET localhost:5000/hdr-suffix suffix + must_fail_http_request GET localhost:5000/hdr-suffix suffix-nope +} + +@test "test regex header" { + must_pass_http_request GET localhost:5000/hdr-regex regex + must_fail_http_request GET localhost:5000/hdr-regex reggex +} + +@test "test method match" { + must_pass_http_request GET localhost:5000/method-match + must_pass_http_request PUT localhost:5000/method-match + must_fail_http_request POST localhost:5000/method-match + must_fail_http_request HEAD localhost:5000/method-match +} + +# @test "s1 upstream should NOT be able to connect to s2" { +# run retry_default must_fail_tcp_connection localhost:5000 + +# echo "OUTPUT $output" + +# [ "$status" == "0" ] +# } diff --git a/test/integration/connect/envoy/helpers.bash b/test/integration/connect/envoy/helpers.bash index 0ba7e2bcc..b4dcda90b 100755 --- a/test/integration/connect/envoy/helpers.bash +++ b/test/integration/connect/envoy/helpers.bash @@ -554,6 +554,73 @@ function must_fail_http_connection { echo "$output" | grep "${expect_response}" } +# must_pass_http_request allows you to craft a specific http request to assert +# that envoy will NOT reject the request. Primarily of use for testing L7 +# intentions. +function must_pass_http_request { + local METHOD=$1 + local URL=$2 + local DEBUG_HEADER_VALUE="${3:-""}" + + local extra_args + if [[ -n "${DEBUG_HEADER_VALUE}" ]]; then + extra_args="-H x-test-debug:${DEBUG_HEADER_VALUE}" + fi + case "$METHOD" in + GET) + ;; + DELETE) + extra_args="$extra_args -X${METHOD}" + ;; + PUT|POST) + extra_args="$extra_args -d'{}' -X${METHOD}" + ;; + *) + return 1 + ;; + esac + + run retry_default curl -v -s -f $extra_args "$URL" + [ "$status" == 0 ] +} + +# must_fail_http_request allows you to craft a specific http request to assert +# that envoy will reject the request. Primarily of use for testing L7 +# intentions. +function must_fail_http_request { + local METHOD=$1 + local URL=$2 + local DEBUG_HEADER_VALUE="${3:-""}" + + local extra_args + if [[ -n "${DEBUG_HEADER_VALUE}" ]]; then + extra_args="-H x-test-debug:${DEBUG_HEADER_VALUE}" + fi + case "$METHOD" in + HEAD) + extra_args="$extra_args -I" + ;; + GET) + ;; + DELETE) + extra_args="$extra_args -X${METHOD}" + ;; + PUT|POST) + extra_args="$extra_args -d'{}' -X${METHOD}" + ;; + *) + return 1 + ;; + esac + + # Attempt to curl through upstream + run retry_default curl -s -i $extra_args "$URL" + + echo "OUTPUT $output" + + echo "$output" | grep "403 Forbidden" +} + function gen_envoy_bootstrap { SERVICE=$1 ADMIN_PORT=$2 diff --git a/test/integration/connect/envoy/main_test.go b/test/integration/connect/envoy/main_test.go index d7cdf2fb2..039f17ff5 100644 --- a/test/integration/connect/envoy/main_test.go +++ b/test/integration/connect/envoy/main_test.go @@ -38,6 +38,7 @@ func TestEnvoy(t *testing.T) { "case-ingress-gateway-simple", "case-ingress-gateway-tls", "case-ingress-mesh-gateways-resolver", + "case-l7-intentions", "case-multidc-rsa-ca", "case-prometheus", "case-statsd-udp", diff --git a/test/integration/connect/envoy/test-envoy-versions.sh b/test/integration/connect/envoy/test-envoy-versions.sh index 961e1219a..5fabcf238 100755 --- a/test/integration/connect/envoy/test-envoy-versions.sh +++ b/test/integration/connect/envoy/test-envoy-versions.sh @@ -6,11 +6,34 @@ unset CDPATH cd "$(dirname "$0")" +## no rbac url_path support + # 1.12.0 + # 1.12.1 + # 1.12.2 + # 1.13.0 + +## does not exist in docker + # 1.13.5 + # 1.14.0 versions=( - 1.15.0 - 1.14.4 - 1.13.4 + 1.12.3 + 1.12.4 + 1.12.5 1.12.6 + 1.12.7 + 1.13.1 + 1.13.2 + 1.13.3 + 1.13.4 + 1.13.6 + 1.14.1 + 1.14.2 + 1.14.3 + 1.14.4 + 1.14.5 + 1.15.0 + 1.15.1 + 1.15.2 ) for v in "${versions[@]}"; do