connect: support defining intentions using layer 7 criteria (#8839)

Extend Consul’s intentions model to allow for request-based access control enforcement for HTTP-like protocols in addition to the existing connection-based enforcement for unspecified protocols (e.g. tcp).
This commit is contained in:
R.B. Boyer 2020-10-06 17:09:13 -05:00 committed by GitHub
parent a12056f57c
commit 35c4efd220
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 4367 additions and 175 deletions

View File

@ -1341,6 +1341,8 @@ func (s *HTTPHandlers) AgentConnectCALeafCert(resp http.ResponseWriter, req *htt
// //
// POST /v1/agent/connect/authorize // 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 // Note: when this logic changes, consider if the Intention.Check RPC method
// also needs to be updated. // also needs to be updated.
func (s *HTTPHandlers) AgentConnectAuthorize(resp http.ResponseWriter, req *http.Request) (interface{}, error) { func (s *HTTPHandlers) AgentConnectAuthorize(resp http.ResponseWriter, req *http.Request) (interface{}, error) {

View File

@ -11,10 +11,15 @@ import (
"github.com/hashicorp/consul/agent/structs" "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 // 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 // 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. // 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 // The ACL token and the auth request are provided and the auth decision (true
// means authorized) and reason string are returned. // 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")) 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] { for _, ixn := range reply.Matches[0] {
if auth, ok := uriService.Authorize(ixn); ok { if _, ok := uriService.Authorize(ixn); ok {
reason = fmt.Sprintf("Matched intention: %s", ixn.String()) 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 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 // No match, we need to determine the default behavior. We do this by

View File

@ -288,10 +288,10 @@ func (s *Intention) Apply(
} }
if prevEntry == nil { if prevEntry == nil {
upsertEntry = args.Intention.ToConfigEntry() upsertEntry = args.Intention.ToConfigEntry(true)
} else { } else {
upsertEntry = prevEntry.Clone() upsertEntry = prevEntry.Clone()
upsertEntry.Sources = append(upsertEntry.Sources, args.Intention.ToSourceIntention()) upsertEntry.Sources = append(upsertEntry.Sources, args.Intention.ToSourceIntention(true))
} }
case structs.IntentionOpUpdate: case structs.IntentionOpUpdate:
@ -306,7 +306,7 @@ func (s *Intention) Apply(
upsertEntry = prevEntry.Clone() upsertEntry = prevEntry.Clone()
for i, src := range upsertEntry.Sources { for i, src := range upsertEntry.Sources {
if src.LegacyID == args.Intention.ID { if src.LegacyID == args.Intention.ID {
upsertEntry.Sources[i] = args.Intention.ToSourceIntention() upsertEntry.Sources[i] = args.Intention.ToSourceIntention(true)
break break
} }
} }
@ -339,7 +339,7 @@ func (s *Intention) Apply(
return fmt.Errorf("Meta must not be specified") return fmt.Errorf("Meta must not be specified")
} }
upsertEntry = args.Intention.ToConfigEntry() upsertEntry = args.Intention.ToConfigEntry(false)
} else { } else {
upsertEntry = prevEntry.Clone() upsertEntry = prevEntry.Clone()
@ -363,13 +363,13 @@ func (s *Intention) Apply(
found := false found := false
for i, src := range upsertEntry.Sources { for i, src := range upsertEntry.Sources {
if src.SourceServiceName() == sn { if src.SourceServiceName() == sn {
upsertEntry.Sources[i] = args.Intention.ToSourceIntention() upsertEntry.Sources[i] = args.Intention.ToSourceIntention(false)
found = true found = true
break break
} }
} }
if !found { 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 // Check tests a source/destination and returns whether it would be allowed
// or denied based on the current ACL configuration. // 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 // Note: Whenever the logic for this method is changed, you should take
// a look at the agent authorize endpoint (agent/agent_endpoint.go) since // a look at the agent authorize endpoint (agent/agent_endpoint.go) since
// the logic there is similar. // the logic there is similar.
@ -802,14 +804,30 @@ func (s *Intention) Check(
return errors.New("internal error loading matches") 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] { for _, ixn := range matches[0] {
if auth, ok := uri.Authorize(ixn); ok { if _, ok := uri.Authorize(ixn); ok {
reply.Allowed = auth ixnMatch = ixn
return nil 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 // No match, we need to determine the default behavior. We do this by
// specifying the anonymous token token, which will get that behavior. // specifying the anonymous token token, which will get that behavior.
// The default behavior if ACLs are disabled is to allow connections // The default behavior if ACLs are disabled is to allow connections

View File

@ -343,6 +343,22 @@ func TestIntentionApply_WithoutIDs(t *testing.T) {
defaultEntMeta := structs.DefaultEnterpriseMeta() 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 { opApply := func(req *structs.IntentionRequest) error {
req.Datacenter = "dc1" req.Datacenter = "dc1"
var ignored string var ignored string
@ -419,6 +435,10 @@ func TestIntentionApply_WithoutIDs(t *testing.T) {
got := resp.Intentions[0] got := resp.Intentions[0]
require.Equal(t, "original", got.Description) 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. // Verify it is in the new-style.
require.Empty(t, got.ID) require.Empty(t, got.ID)
require.True(t, got.CreatedAt.IsZero()) require.True(t, got.CreatedAt.IsZero())
@ -483,6 +503,10 @@ func TestIntentionApply_WithoutIDs(t *testing.T) {
got := resp.Intentions[0] got := resp.Intentions[0]
require.Equal(t, "updated", got.Description) 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. // Verify it is in the new-style.
require.Empty(t, got.ID) require.Empty(t, got.ID)
require.True(t, got.CreatedAt.IsZero()) require.True(t, got.CreatedAt.IsZero())
@ -502,8 +526,15 @@ func TestIntentionApply_WithoutIDs(t *testing.T) {
Intention: &structs.Intention{ Intention: &structs.Intention{
SourceName: "assay", SourceName: "assay",
DestinationName: "test", DestinationName: "test",
Action: structs.IntentionActionDeny,
Description: "original-2", 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] got := resp.Intentions[0]
require.Equal(t, "original-2", got.Description) 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. // Verify it is in the new-style.
require.Empty(t, got.ID) require.Empty(t, got.ID)
require.True(t, got.CreatedAt.IsZero()) require.True(t, got.CreatedAt.IsZero())
@ -556,10 +598,17 @@ func TestIntentionApply_WithoutIDs(t *testing.T) {
{ {
Name: "assay", Name: "assay",
EnterpriseMeta: *defaultEntMeta, EnterpriseMeta: *defaultEntMeta,
Action: structs.IntentionActionDeny,
Description: "original-2", Description: "original-2",
Precedence: 9, Precedence: 9,
Type: structs.IntentionSourceConsul, Type: structs.IntentionSourceConsul,
Permissions: []*structs.IntentionPermission{
{
Action: structs.IntentionActionAllow,
HTTP: &structs.IntentionHTTPPermission{
PathExact: "/foo",
},
},
},
}, },
}, },
RaftIndex: entry.RaftIndex, RaftIndex: entry.RaftIndex,
@ -609,10 +658,17 @@ func TestIntentionApply_WithoutIDs(t *testing.T) {
{ {
Name: "assay", Name: "assay",
EnterpriseMeta: *defaultEntMeta, EnterpriseMeta: *defaultEntMeta,
Action: structs.IntentionActionDeny,
Description: "original-2", Description: "original-2",
Precedence: 9, Precedence: 9,
Type: structs.IntentionSourceConsul, Type: structs.IntentionSourceConsul,
Permissions: []*structs.IntentionPermission{
{
Action: structs.IntentionActionAllow,
HTTP: &structs.IntentionHTTPPermission{
PathExact: "/foo",
},
},
},
}, },
}, },
RaftIndex: entry.RaftIndex, RaftIndex: entry.RaftIndex,

View File

@ -383,6 +383,16 @@ func (s *Server) replicateLegacyIntentionsOnce(ctx context.Context, lastFetchInd
return 0, false, err 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. // Compute the diff between the remote and local intentions.
deletes, updates := diffIntentions(local, remote.Intentions) deletes, updates := diffIntentions(local, remote.Intentions)
txnOpSets := batchLegacyIntentionUpdates(deletes, updates) txnOpSets := batchLegacyIntentionUpdates(deletes, updates)

View File

@ -324,7 +324,7 @@ func insertConfigEntryWithTxn(tx *txn, idx uint64, conf structs.ConfigEntry) err
func validateProposedConfigEntryInGraph( func validateProposedConfigEntryInGraph(
tx ReadTxn, tx ReadTxn,
kind, name string, kind, name string,
next structs.ConfigEntry, proposedEntry structs.ConfigEntry,
entMeta *structs.EnterpriseMeta, entMeta *structs.EnterpriseMeta,
) error { ) error {
validateAllChains := false validateAllChains := false
@ -350,14 +350,11 @@ func validateProposedConfigEntryInGraph(
return err return err
} }
case structs.ServiceIntentions: case structs.ServiceIntentions:
// TODO(rb): should this validate protocols?
return nil
default: default:
return fmt.Errorf("unhandled kind %q during validation of %q", kind, name) 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( func checkGatewayClash(
@ -475,7 +472,7 @@ func (s *Store) discoveryChainSourcesTxn(tx ReadTxn, ws memdb.WatchSet, dc strin
func validateProposedConfigEntryInServiceGraph( func validateProposedConfigEntryInServiceGraph(
tx ReadTxn, tx ReadTxn,
kind, name string, kind, name string,
next structs.ConfigEntry, proposedEntry structs.ConfigEntry,
validateAllChains bool, validateAllChains bool,
entMeta *structs.EnterpriseMeta, entMeta *structs.EnterpriseMeta,
) error { ) error {
@ -484,6 +481,7 @@ func validateProposedConfigEntryInServiceGraph(
var ( var (
checkChains = make(map[structs.ServiceID]struct{}) checkChains = make(map[structs.ServiceID]struct{})
checkIngress []*structs.IngressGatewayConfigEntry checkIngress []*structs.IngressGatewayConfigEntry
checkIntentions []*structs.ServiceIntentionsConfigEntry
enforceIngressProtocolsMatch bool 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 { if err != nil {
return err return err
} }
for _, entry := range entries { for _, entry := range ingressEntries {
ingress, ok := entry.(*structs.IngressGatewayConfigEntry) ingress, ok := entry.(*structs.IngressGatewayConfigEntry)
if !ok { if !ok {
return fmt.Errorf("type %T is not an ingress gateway config entry", entry) return fmt.Errorf("type %T is not an ingress gateway config entry", entry)
@ -515,17 +513,43 @@ func validateProposedConfigEntryInServiceGraph(
checkIngress = append(checkIngress, ingress) 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 { } else if kind == structs.IngressGateway {
// Checking an ingress pointing to multiple chains. // Checking an ingress pointing to multiple chains.
// This is the case for deleting a config entry // This is the case for deleting a config entry
if next == nil { if proposedEntry == nil {
return nil return nil
} }
ingress, ok := next.(*structs.IngressGatewayConfigEntry) ingress, ok := proposedEntry.(*structs.IngressGatewayConfigEntry)
if !ok { 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) checkIngress = append(checkIngress, ingress)
@ -536,6 +560,28 @@ func validateProposedConfigEntryInServiceGraph(
} else { } else {
// Must be a single chain. // 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) sid := structs.NewServiceID(name, entMeta)
checkChains[sid] = struct{}{} checkChains[sid] = struct{}{}
@ -559,16 +605,20 @@ func validateProposedConfigEntryInServiceGraph(
} }
} }
// Ensure if any ingress is affected that we fetch all of the chains needed // Ensure if any ingress or intention is affected that we fetch all of the
// to fully validate that ingress. // chains needed to fully validate them.
for _, ingress := range checkIngress { for _, ingress := range checkIngress {
for _, svcID := range ingress.ListRelatedServices() { for _, svcID := range ingress.ListRelatedServices() {
checkChains[svcID] = struct{}{} checkChains[svcID] = struct{}{}
} }
} }
for _, ixn := range checkIntentions {
sn := ixn.DestinationServiceName()
checkChains[sn.ToServiceID()] = struct{}{}
}
overrides := map[structs.ConfigEntryKindName]structs.ConfigEntry{ overrides := map[structs.ConfigEntryKindName]structs.ConfigEntry{
structs.NewConfigEntryKindName(kind, name, entMeta): next, structs.NewConfigEntryKindName(kind, name, entMeta): proposedEntry,
} }
var ( 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 return nil
} }

View File

@ -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)
}
}
})
}
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
"net/http"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
@ -108,15 +109,12 @@ func (e *ServiceRouterConfigEntry) Normalize() error {
} }
httpMatch := route.Match.HTTP httpMatch := route.Match.HTTP
if len(httpMatch.Methods) == 0 {
continue
}
for j := 0; j < len(httpMatch.Methods); j++ { for j := 0; j < len(httpMatch.Methods); j++ {
httpMatch.Methods[j] = strings.ToUpper(httpMatch.Methods[j]) httpMatch.Methods[j] = strings.ToUpper(httpMatch.Methods[j])
} }
if route.Destination != nil && route.Destination.Namespace == "" { 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 { if len(route.Match.HTTP.Methods) > 0 {
found := make(map[string]struct{}) found := make(map[string]struct{})
for _, m := range route.Match.HTTP.Methods { 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 { if _, ok := found[m]; ok {
return fmt.Errorf("Route[%d] Methods contains %q more than once", i, m) return fmt.Errorf("Route[%d] Methods contains %q more than once", i, m)
} }
@ -227,6 +228,23 @@ func (e *ServiceRouterConfigEntry) Validate() error {
return nil 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 { func (e *ServiceRouterConfigEntry) CanRead(rule acl.Authorizer) bool {
return canReadDiscoveryChain(e, rule) return canReadDiscoveryChain(e, rule)
} }

View File

@ -72,6 +72,7 @@ func (e *ServiceIntentionsConfigEntry) ToIntention(src *SourceIntention) *Intent
SourceName: src.Name, SourceName: src.Name,
SourceType: src.Type, SourceType: src.Type,
Action: src.Action, Action: src.Action,
Permissions: src.Permissions,
Meta: meta, Meta: meta,
Precedence: src.Precedence, Precedence: src.Precedence,
DestinationNS: e.NamespaceOrDefault(), DestinationNS: e.NamespaceOrDefault(),
@ -135,7 +136,27 @@ type SourceIntention struct {
// Action is whether this is an allowlist or denylist intention. // Action is whether this is an allowlist or denylist intention.
// //
// formerly Intention.Action // 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 // Precedence is the order that the intention will be applied, with
// larger numbers being applied first. This is a read-only field, on // larger numbers being applied first. This is a read-only field, on
@ -182,6 +203,65 @@ type SourceIntention struct {
EnterpriseMeta `hcl:",squash" mapstructure:",squash"` 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 { func cloneStringStringMap(m map[string]string) map[string]string {
if m == nil { if m == nil {
return nil return nil
@ -202,6 +282,13 @@ func (x *SourceIntention) Clone() *SourceIntention {
x2.LegacyMeta = cloneStringStringMap(x.LegacyMeta) 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 return &x2
} }
@ -303,6 +390,16 @@ func (e *ServiceIntentionsConfigEntry) normalize(legacyWrite bool) error {
src.LegacyCreateTime = nil src.LegacyCreateTime = nil
src.LegacyUpdateTime = 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 // 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) 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 { func (e *ServiceIntentionsConfigEntry) validate(legacyWrite bool) error {
if e.Name == "" { if e.Name == "" {
return fmt.Errorf("Name is required") return fmt.Errorf("Name is required")
@ -379,6 +490,8 @@ func (e *ServiceIntentionsConfigEntry) validate(legacyWrite bool) error {
return err return err
} }
destIsWild := e.HasWildcardDestination()
if legacyWrite { if legacyWrite {
if len(e.Meta) > 0 { if len(e.Meta) > 0 {
return fmt.Errorf("Meta must be omitted for legacy intention writes") 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 { if legacyWrite || len(src.Permissions) == 0 {
case IntentionActionAllow, IntentionActionDeny: switch src.Action {
default: case IntentionActionAllow, IntentionActionDeny:
return fmt.Errorf("Sources[%d].Action must be set to 'allow' or 'deny'", i) 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 { 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) 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() serviceName := src.SourceServiceName()
if _, exists := seenSources[serviceName]; exists { if _, exists := seenSources[serviceName]; exists {
return fmt.Errorf("Sources[%d] defines %q more than once", i, serviceName.String()) 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) collated := make(map[ServiceName]*ServiceIntentionsConfigEntry)
for _, ixn := range ixns { for _, ixn := range ixns {
thisEntry := ixn.ToConfigEntry() thisEntry := ixn.ToConfigEntry(true)
sn := thisEntry.DestinationServiceName() sn := thisEntry.DestinationServiceName()
if entry, ok := collated[sn]; ok { if entry, ok := collated[sn]; ok {

View File

@ -315,6 +315,688 @@ func TestServiceIntentionsConfigEntry(t *testing.T) {
}, },
validateErr: `Sources[0].Action must be set to 'allow' or 'deny'`, 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": { "L4 normalize": {
entry: &ServiceIntentionsConfigEntry{ entry: &ServiceIntentionsConfigEntry{
Kind: ServiceIntentions, 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 { for name, tc := range cases {
tc := tc tc := tc

View File

@ -973,6 +973,57 @@ func TestDecodeConfigEntry(t *testing.T) {
name = "bar" name = "bar"
action = "allow" action = "allow"
description = "bar desc" 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 { sources {
@ -999,6 +1050,57 @@ func TestDecodeConfigEntry(t *testing.T) {
Name = "bar" Name = "bar"
Action = "allow" Action = "allow"
Description = "bar desc" 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 { Sources {
@ -1026,6 +1128,57 @@ func TestDecodeConfigEntry(t *testing.T) {
Action: "allow", Action: "allow",
Description: "bar desc", 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: "*", Name: "*",
Action: "deny", Action: "deny",

View File

@ -55,7 +55,14 @@ type Intention struct {
SourceType IntentionSourceType SourceType IntentionSourceType
// Action is whether this is an allowlist or denylist intention. // 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. // DefaultAddr is not used.
// Deprecated: DefaultAddr is not used and may be removed in a future version. // Deprecated: DefaultAddr is not used and may be removed in a future version.
@ -77,10 +84,11 @@ type Intention struct {
// or modified. // or modified.
CreatedAt, UpdatedAt time.Time `mapstructure:"-" bexpr:"-"` 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 // This is needed mainly for legacy replication purposes. When replicating
// one DC to another keeping the content Hash will allow us to detect // from one DC to another keeping the content Hash will allow us to detect
// content changes more efficiently than checking every single field // content changes more efficiently than checking every single field
Hash []byte `bexpr:"-" json:",omitempty"` Hash []byte `bexpr:"-" json:",omitempty"`
@ -89,6 +97,12 @@ type Intention struct {
func (t *Intention) Clone() *Intention { func (t *Intention) Clone() *Intention {
t2 := *t 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.Meta = cloneStringStringMap(t.Meta)
t2.Hash = nil t2.Hash = nil
return &t2 return &t2
@ -267,6 +281,11 @@ func (x *Intention) Validate() error {
"Action must be set to 'allow' or 'deny'")) "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 { switch x.SourceType {
case IntentionSourceConsul: case IntentionSourceConsul:
default: default:
@ -365,11 +384,25 @@ func (x *Intention) countExact(ns, n string) int {
// String returns a human-friendly string for this intention. // String returns a human-friendly string for this intention.
func (x *Intention) String() string { func (x *Intention) String() string {
return fmt.Sprintf("%s %s/%s => %s/%s (ID: %s, Precedence: %d)", var idPart string
strings.ToUpper(string(x.Action)), 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.SourceNS, x.SourceName,
x.DestinationNS, x.DestinationName, 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. // 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 // NOTE this is just used to manipulate user-provided data before an insert
// The RPC execution will do Normalize + Validate for us. // The RPC execution will do Normalize + Validate for us.
func (x *Intention) ToConfigEntry() *ServiceIntentionsConfigEntry { func (x *Intention) ToConfigEntry(legacy bool) *ServiceIntentionsConfigEntry {
return &ServiceIntentionsConfigEntry{ return &ServiceIntentionsConfigEntry{
Kind: ServiceIntentions, Kind: ServiceIntentions,
Name: x.DestinationName, Name: x.DestinationName,
EnterpriseMeta: *x.DestinationEnterpriseMeta(), EnterpriseMeta: *x.DestinationEnterpriseMeta(),
Sources: []*SourceIntention{x.ToSourceIntention()}, Sources: []*SourceIntention{x.ToSourceIntention(legacy)},
} }
} }
func (x *Intention) ToSourceIntention() *SourceIntention { func (x *Intention) ToSourceIntention(legacy bool) *SourceIntention {
return &SourceIntention{ src := &SourceIntention{
Name: x.SourceName, Name: x.SourceName,
EnterpriseMeta: *x.SourceEnterpriseMeta(), EnterpriseMeta: *x.SourceEnterpriseMeta(),
Action: x.Action, 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, LegacyID: x.ID,
Type: x.SourceType, Type: x.SourceType,
Description: x.Description, Description: x.Description,
@ -419,6 +453,10 @@ func (x *Intention) ToSourceIntention() *SourceIntention {
LegacyCreateTime: nil, // Ignore LegacyCreateTime: nil, // Ignore
LegacyUpdateTime: nil, // Ignore LegacyUpdateTime: nil, // Ignore
} }
if !legacy {
src.Permissions = x.Permissions
}
return src
} }
// IntentionAction is the action that the intention represents. This // IntentionAction is the action that the intention represents. This

View File

@ -359,3 +359,97 @@ func TestIntention_SetHash(t *testing.T) {
} }
require.Equal(t, expected, i.Hash) 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)
})
}
}

View File

@ -12,8 +12,37 @@ var (
// minSupportedVersion is the oldest mainline version we support. This should always be // minSupportedVersion is the oldest mainline version we support. This should always be
// the zero'th point release of the last element of proxysupport.EnvoyVersions. // the zero'th point release of the last element of proxysupport.EnvoyVersions.
minSupportedVersion = version.Must(version.NewVersion("1.12.0")) 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 { type supportedProxyFeatures struct {
// add version dependent feature flags here // 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) 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 return supportedProxyFeatures{}, nil
} }

View File

@ -5,6 +5,7 @@ import (
envoycore "github.com/envoyproxy/go-control-plane/envoy/api/v2/core" envoycore "github.com/envoyproxy/go-control-plane/envoy/api/v2/core"
envoytype "github.com/envoyproxy/go-control-plane/envoy/type" envoytype "github.com/envoyproxy/go-control-plane/envoy/type"
"github.com/hashicorp/consul/sdk/testutil"
"github.com/hashicorp/go-version" "github.com/hashicorp/go-version"
"github.com/stretchr/testify/require" "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)
}
})
}
}

View File

@ -3,8 +3,10 @@ package xds
import ( import (
"fmt" "fmt"
"sort" "sort"
"strings"
envoylistener "github.com/envoyproxy/go-control-plane/envoy/api/v2/listener" 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" 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" 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" 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) { func makeRBACNetworkFilter(intentions structs.Intentions, intentionDefaultAllow bool) (*envoylistener.Filter, error) {
rules, err := makeRBACRules(intentions, intentionDefaultAllow) rules, err := makeRBACRules(intentions, intentionDefaultAllow, false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -27,7 +29,7 @@ func makeRBACNetworkFilter(intentions structs.Intentions, intentionDefaultAllow
} }
func makeRBACHTTPFilter(intentions structs.Intentions, intentionDefaultAllow bool) (*envoyhttp.HttpFilter, error) { func makeRBACHTTPFilter(intentions structs.Intentions, intentionDefaultAllow bool) (*envoyhttp.HttpFilter, error) {
rules, err := makeRBACRules(intentions, intentionDefaultAllow) rules, err := makeRBACRules(intentions, intentionDefaultAllow, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -38,16 +40,238 @@ func makeRBACHTTPFilter(intentions structs.Intentions, intentionDefaultAllow boo
return makeEnvoyHTTPFilter("envoy.filters.http.rbac", cfg) return makeEnvoyHTTPFilter("envoy.filters.http.rbac", cfg)
} }
type rbacIntention struct { func intentionListToIntermediateRBACForm(intentions structs.Intentions, isHTTP bool) []*rbacIntention {
Source structs.ServiceName sort.Sort(structs.IntentionPrecedenceSorter(intentions))
NotSources []structs.ServiceName
Allow bool // Omit any lower-precedence intentions that share the same source.
Precedence int intentions = removeSameSourceIntentions(intentions)
Skip bool
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) 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 { func simplifyNotSourceSlice(notSources []structs.ServiceName) []structs.ServiceName {
@ -132,7 +356,7 @@ func simplifyNotSourceSlice(notSources []structs.ServiceName) []structs.ServiceN
// <default> : DENY // <default> : DENY
// //
// Which really is just an allow-list of [A, C AND NOT(B)] // 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. // 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. // 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? // 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. // First build up just the basic principal matches.
rbacIxns := make([]*rbacIntention, 0, len(intentions)) rbacIxns := intentionListToIntermediateRBACForm(intentions, isHTTP)
for _, ixn := range intentions {
rbacIxns = append(rbacIxns, &rbacIntention{
Source: ixn.SourceServiceName(),
Allow: (ixn.Action == structs.IntentionActionAllow),
Precedence: ixn.Precedence,
})
}
// Normalize: if we are in default-deny then all intentions must be allows and vice versa // Normalize: if we are in default-deny then all intentions must be allows and vice versa
intentionDefaultAction := intentionActionFromBool(intentionDefaultAllow)
var rbacAction envoyrbac.RBAC_Action var rbacAction envoyrbac.RBAC_Action
if intentionDefaultAllow { if intentionDefaultAllow {
@ -173,69 +388,46 @@ func makeRBACRules(intentions structs.Intentions, intentionDefaultAllow bool) (*
rbacAction = envoyrbac.RBAC_ALLOW rbacAction = envoyrbac.RBAC_ALLOW
} }
// First walk backwards and if we encounter an intention with an action // Remove source and permissions precedence.
// that is the same as the default intention action, add it to all rbacIxns = removeIntentionPrecedence(rbacIxns, intentionDefaultAction)
// 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))
}
}
// 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{ rbac := &envoyrbac.RBAC{
Action: rbacAction, Action: rbacAction,
Policies: make(map[string]*envoyrbac.Policy),
} }
if len(principals) > 0 {
policy := &envoyrbac.Policy{ var principalsL4 []*envoyrbac.Principal
Principals: principals, 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()}, Permissions: []*envoyrbac.Permission{anyPermission()},
} }
rbac.Policies = map[string]*envoyrbac.Policy{
"consul-intentions": policy,
}
} }
if len(rbac.Policies) == 0 {
rbac.Policies = nil
}
return rbac, nil return rbac, nil
} }
@ -267,14 +459,6 @@ func removeSameSourceIntentions(intentions structs.Intentions) structs.Intention
return out 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 // ixnSourceMatches deterines if the 'tester' service name is matched by the
// 'against' service name via wildcard rules. // 'against' service name via wildcard rules.
// //
@ -372,3 +556,142 @@ func anyPermission() *envoyrbac.Permission {
Rule: &envoyrbac.Permission_Any{Any: true}, 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,
},
},
}
}
}

View File

@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/require" "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 { testIntention := func(t *testing.T, src, dst string, action structs.IntentionAction) *structs.Intention {
t.Helper() t.Helper()
ixn := structs.TestIntention(t) ixn := structs.TestIntention(t)
@ -25,6 +25,11 @@ func TestMakeRBACNetworkFilter(t *testing.T) {
testSourceIntention := func(src string, action structs.IntentionAction) *structs.Intention { testSourceIntention := func(src string, action structs.IntentionAction) *structs.Intention {
return testIntention(t, src, "api", action) 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 { sorted := func(ixns ...*structs.Intention) structs.Intentions {
sort.SliceStable(ixns, func(i, j int) bool { sort.SliceStable(ixns, func(i, j int) bool {
return ixns[j].Precedence < ixns[i].Precedence return ixns[j].Precedence < ixns[i].Precedence
@ -97,17 +102,163 @@ func TestMakeRBACNetworkFilter(t *testing.T) {
testSourceIntention("*", structs.IntentionActionDeny), 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 { for name, tt := range tests {
tt := tt tt := tt
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
filter, err := makeRBACNetworkFilter(tt.intentions, tt.intentionDefaultAllow) t.Run("network filter", func(t *testing.T) {
require.NoError(t, err) 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)
})
}) })
} }
} }

View File

@ -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$"
}
}
}
}
}
]
}
}
]
}
}
}
}
}

View File

@ -4,7 +4,7 @@
"rules": { "rules": {
"action": "DENY", "action": "DENY",
"policies": { "policies": {
"consul-intentions": { "consul-intentions-layer4": {
"permissions": [ "permissions": [
{ {
"any": true "any": true
@ -17,7 +17,7 @@
"safe_regex": { "safe_regex": {
"google_re2": { "google_re2": {
}, },
"regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$"
} }
} }
} }
@ -28,7 +28,7 @@
"safe_regex": { "safe_regex": {
"google_re2": { "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": { "not_id": {
"authenticated": { "authenticated": {
@ -59,6 +72,19 @@
} }
} }
} }
},
{
"not_id": {
"authenticated": {
"principal_name": {
"safe_regex": {
"google_re2": {
},
"regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$"
}
}
}
}
} }
] ]
} }

View File

@ -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$"
}
}
}
}
]
}
}
}
}
}

View File

@ -4,7 +4,7 @@
"rules": { "rules": {
"action": "DENY", "action": "DENY",
"policies": { "policies": {
"consul-intentions": { "consul-intentions-layer4": {
"permissions": [ "permissions": [
{ {
"any": true "any": true

View File

@ -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/[^/]+$"
}
}
}
}
]
}
}
}
}
}

View File

@ -4,7 +4,7 @@
"rules": { "rules": {
"action": "DENY", "action": "DENY",
"policies": { "policies": {
"consul-intentions": { "consul-intentions-layer4": {
"permissions": [ "permissions": [
{ {
"any": true "any": true

View File

@ -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$"
}
}
}
}
]
}
}
}
}
}

View File

@ -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"
}
}

View File

@ -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$"
}
}
}
}
]
}
}
}
}
}

View File

@ -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"
}
}

View File

@ -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$"
}
}
}
}
}
]
}
}
]
}
}
}
}
}

View File

@ -3,7 +3,7 @@
"config": { "config": {
"rules": { "rules": {
"policies": { "policies": {
"consul-intentions": { "consul-intentions-layer4": {
"permissions": [ "permissions": [
{ {
"any": true "any": true

View File

@ -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$"
}
}
}
}
}
]
}
}
]
}
}
}
}
}

View File

@ -3,7 +3,7 @@
"config": { "config": {
"rules": { "rules": {
"policies": { "policies": {
"consul-intentions": { "consul-intentions-layer4": {
"permissions": [ "permissions": [
{ {
"any": true "any": true
@ -16,7 +16,7 @@
"safe_regex": { "safe_regex": {
"google_re2": { "google_re2": {
}, },
"regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/web$" "regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$"
} }
} }
} }
@ -27,7 +27,7 @@
"safe_regex": { "safe_regex": {
"google_re2": { "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": { "not_id": {
"authenticated": { "authenticated": {
@ -58,6 +71,19 @@
} }
} }
} }
},
{
"not_id": {
"authenticated": {
"principal_name": {
"safe_regex": {
"google_re2": {
},
"regex": "^spiffe://[^/]+/ns/default/dc/[^/]+/svc/cron$"
}
}
}
}
} }
] ]
} }

View File

@ -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$"
}
}
}
}
]
}
}
}
}
}

View File

@ -3,7 +3,7 @@
"config": { "config": {
"rules": { "rules": {
"policies": { "policies": {
"consul-intentions": { "consul-intentions-layer4": {
"permissions": [ "permissions": [
{ {
"any": true "any": true

View File

@ -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$"
}
}
}
}
]
}
}
}
}
}

View File

@ -3,7 +3,7 @@
"config": { "config": {
"rules": { "rules": {
"policies": { "policies": {
"consul-intentions": { "consul-intentions-layer4": {
"permissions": [ "permissions": [
{ {
"any": true "any": true

View File

@ -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/[^/]+$"
}
}
}
}
]
}
}
}
}
}

View File

@ -3,7 +3,7 @@
"config": { "config": {
"rules": { "rules": {
"policies": { "policies": {
"consul-intentions": { "consul-intentions-layer4": {
"permissions": [ "permissions": [
{ {
"any": true "any": true

View File

@ -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$"
}
}
}
}
]
}
}
}
}
}

View File

@ -0,0 +1,8 @@
{
"name": "envoy.filters.network.rbac",
"config": {
"rules": {
},
"stat_prefix": "connect_authz"
}
}

View File

@ -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$"
}
}
}
}
]
}
}
}
}
}

View File

@ -0,0 +1,8 @@
{
"name": "envoy.filters.network.rbac",
"config": {
"rules": {
},
"stat_prefix": "connect_authz"
}
}

View File

@ -17,8 +17,9 @@ type ServiceIntentionsConfigEntry struct {
type SourceIntention struct { type SourceIntention struct {
Name string Name string
Namespace string `json:",omitempty"` Namespace string `json:",omitempty"`
Action IntentionAction Action IntentionAction `json:",omitempty"`
Permissions []*IntentionPermission `json:",omitempty"`
Precedence int Precedence int
Type IntentionSourceType Type IntentionSourceType
Description string `json:",omitempty"` Description string `json:",omitempty"`
@ -52,3 +53,28 @@ func (e *ServiceIntentionsConfigEntry) GetCreateIndex() uint64 {
func (e *ServiceIntentionsConfigEntry) GetModifyIndex() uint64 { func (e *ServiceIntentionsConfigEntry) GetModifyIndex() uint64 {
return e.ModifyIndex 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"`
}

View File

@ -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 tc := tc

View File

@ -34,7 +34,14 @@ type Intention struct {
SourceType IntentionSourceType SourceType IntentionSourceType
// Action is whether this is an allowlist or denylist intention. // 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. // DefaultAddr is not used.
// Deprecated: DefaultAddr is not used and may be removed in a future version. // 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. // String returns human-friendly output describing ths intention.
func (i *Intention) String() string { 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)", return fmt.Sprintf("%s => %s (%s)",
i.SourceString(), i.SourceString(),
i.DestinationString(), i.DestinationString(),
i.Action) detail)
} }
// SourceString returns the namespace/name format for the source, or // SourceString returns the namespace/name format for the source, or

View File

@ -1917,8 +1917,8 @@ func TestParseConfigEntry(t *testing.T) {
kind = "service-intentions" kind = "service-intentions"
name = "web" name = "web"
meta { meta {
"foo" = "bar" "foo" = "bar"
"gir" = "zim" "gir" = "zim"
} }
sources = [ sources = [
{ {
@ -1931,6 +1931,57 @@ func TestParseConfigEntry(t *testing.T) {
name = "bar" name = "bar"
action = "allow" action = "allow"
description = "bar desc" 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 { sources {
@ -1957,6 +2008,57 @@ func TestParseConfigEntry(t *testing.T) {
Name = "bar" Name = "bar"
Action = "allow" Action = "allow"
Description = "bar desc" 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 { Sources {
@ -1969,7 +2071,7 @@ func TestParseConfigEntry(t *testing.T) {
{ {
"kind": "service-intentions", "kind": "service-intentions",
"name": "web", "name": "web",
"meta" : { "meta": {
"foo": "bar", "foo": "bar",
"gir": "zim" "gir": "zim"
}, },
@ -1985,6 +2087,60 @@ func TestParseConfigEntry(t *testing.T) {
"action": "allow", "action": "allow",
"description": "bar desc" "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": "*", "name": "*",
"action": "deny", "action": "deny",
@ -2013,6 +2169,60 @@ func TestParseConfigEntry(t *testing.T) {
"Action": "allow", "Action": "allow",
"Description": "bar desc" "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": "*", "Name": "*",
"Action": "deny", "Action": "deny",
@ -2040,6 +2250,57 @@ func TestParseConfigEntry(t *testing.T) {
Action: "allow", Action: "allow",
Description: "bar desc", 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: "*", Name: "*",
Action: "deny", Action: "deny",

View File

@ -177,24 +177,36 @@ func (c *cmd) ixnsFromArgs(args []string) ([]*api.Intention, error) {
func (c *cmd) ixnsFromFiles(args []string) ([]*api.Intention, error) { func (c *cmd) ixnsFromFiles(args []string) ([]*api.Intention, error) {
var result []*api.Intention var result []*api.Intention
for _, path := range args { for _, path := range args {
f, err := os.Open(path) ixn, err := c.ixnFromFile(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var ixn api.Intention result = append(result, ixn)
err = json.NewDecoder(f).Decode(&ixn)
f.Close()
if err != nil {
return nil, err
}
result = append(result, &ixn)
} }
return result, nil 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. // ixnAction returns the api.IntentionAction based on the flag set.
func (c *cmd) ixnAction() api.IntentionAction { func (c *cmd) ixnAction() api.IntentionAction {
if c.flagAllow { if c.flagAllow {

View File

@ -174,6 +174,47 @@ func TestIntentionCreate_File(t *testing.T) {
require.Equal(api.IntentionActionAllow, ixns[0].Action) 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) { func TestIntentionCreate_FileNoExist(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -85,6 +85,28 @@ func TestIntentionDelete(t *testing.T) {
}, nil) }, nil)
require.NoError(t, err) 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("l4 intention", func(t *testing.T) {
t.Run("one arg", func(t *testing.T) { t.Run("one arg", func(t *testing.T) {
ui := cli.NewMockUi() 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. // They should all be gone.
{ {
ixns, _, err := client.Connect().Intentions(nil) ixns, _, err := client.Connect().Intentions(nil)

View File

@ -1,6 +1,7 @@
package get package get
import ( import (
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@ -60,7 +61,9 @@ func (c *cmd) Run(args []string) int {
data := []string{ data := []string{
fmt.Sprintf("Source:\x1f%s", ixn.SourceString()), fmt.Sprintf("Source:\x1f%s", ixn.SourceString()),
fmt.Sprintf("Destination:\x1f%s", ixn.DestinationString()), 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 != "" { if ixn.ID != "" {
data = append(data, fmt.Sprintf("ID:\x1f%s", 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})})) 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 return 0
} }

View File

@ -28,6 +28,28 @@ func TestGetFromArgs(t *testing.T) {
}, nil) }, nil)
require.NoError(t, err) 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("l4 intention", func(t *testing.T) {
t.Run("one arg", func(t *testing.T) { t.Run("one arg", func(t *testing.T) {
ixn, err := GetFromArgs(client, []string{id0}) ixn, err := GetFromArgs(client, []string{id0})
@ -36,6 +58,7 @@ func TestGetFromArgs(t *testing.T) {
require.Equal(t, "a", ixn.SourceName) require.Equal(t, "a", ixn.SourceName)
require.Equal(t, "b", ixn.DestinationName) require.Equal(t, "b", ixn.DestinationName)
require.Equal(t, api.IntentionActionAllow, ixn.Action) require.Equal(t, api.IntentionActionAllow, ixn.Action)
require.Empty(t, ixn.Permissions)
}) })
t.Run("two args", func(t *testing.T) { t.Run("two args", func(t *testing.T) {
ixn, err := GetFromArgs(client, []string{"a", "b"}) 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, "a", ixn.SourceName)
require.Equal(t, "b", ixn.DestinationName) require.Equal(t, "b", ixn.DestinationName)
require.Equal(t, api.IntentionActionAllow, ixn.Action) 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)
}) })
}) })

View File

@ -0,0 +1,4 @@
#!/bin/bash
snapshot_envoy_admin localhost:19000 s1 primary || true
snapshot_envoy_admin localhost:19001 s2 || true

View File

@ -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"]
}
}
]
}
}
}

View File

@ -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

View File

@ -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" ]
# }

View File

@ -554,6 +554,73 @@ function must_fail_http_connection {
echo "$output" | grep "${expect_response}" 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 { function gen_envoy_bootstrap {
SERVICE=$1 SERVICE=$1
ADMIN_PORT=$2 ADMIN_PORT=$2

View File

@ -38,6 +38,7 @@ func TestEnvoy(t *testing.T) {
"case-ingress-gateway-simple", "case-ingress-gateway-simple",
"case-ingress-gateway-tls", "case-ingress-gateway-tls",
"case-ingress-mesh-gateways-resolver", "case-ingress-mesh-gateways-resolver",
"case-l7-intentions",
"case-multidc-rsa-ca", "case-multidc-rsa-ca",
"case-prometheus", "case-prometheus",
"case-statsd-udp", "case-statsd-udp",

View File

@ -6,11 +6,34 @@ unset CDPATH
cd "$(dirname "$0")" 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=( versions=(
1.15.0 1.12.3
1.14.4 1.12.4
1.13.4 1.12.5
1.12.6 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 for v in "${versions[@]}"; do