open-consul/agent/xds/rbac.go

698 lines
21 KiB
Go
Raw Normal View History

package xds
import (
"fmt"
"sort"
"strings"
envoylistener "github.com/envoyproxy/go-control-plane/envoy/api/v2/listener"
envoyroute "github.com/envoyproxy/go-control-plane/envoy/api/v2/route"
envoyhttprbac "github.com/envoyproxy/go-control-plane/envoy/config/filter/http/rbac/v2"
envoyhttp "github.com/envoyproxy/go-control-plane/envoy/config/filter/network/http_connection_manager/v2"
envoynetrbac "github.com/envoyproxy/go-control-plane/envoy/config/filter/network/rbac/v2"
envoyrbac "github.com/envoyproxy/go-control-plane/envoy/config/rbac/v2"
envoymatcher "github.com/envoyproxy/go-control-plane/envoy/type/matcher"
"github.com/hashicorp/consul/agent/structs"
)
func makeRBACNetworkFilter(intentions structs.Intentions, intentionDefaultAllow bool) (*envoylistener.Filter, error) {
rules, err := makeRBACRules(intentions, intentionDefaultAllow, false)
if err != nil {
return nil, err
}
cfg := &envoynetrbac.RBAC{
StatPrefix: "connect_authz",
Rules: rules,
}
return makeFilter("envoy.filters.network.rbac", cfg, false)
}
func makeRBACHTTPFilter(intentions structs.Intentions, intentionDefaultAllow bool) (*envoyhttp.HttpFilter, error) {
rules, err := makeRBACRules(intentions, intentionDefaultAllow, true)
if err != nil {
return nil, err
}
cfg := &envoyhttprbac.RBAC{
Rules: rules,
}
return makeEnvoyHTTPFilter("envoy.filters.http.rbac", cfg)
}
func intentionListToIntermediateRBACForm(intentions structs.Intentions, isHTTP bool) []*rbacIntention {
sort.Sort(structs.IntentionPrecedenceSorter(intentions))
// Omit any lower-precedence intentions that share the same source.
intentions = removeSameSourceIntentions(intentions)
rbacIxns := make([]*rbacIntention, 0, len(intentions))
for _, ixn := range intentions {
rixn := intentionToIntermediateRBACForm(ixn, isHTTP)
rbacIxns = append(rbacIxns, rixn)
}
return rbacIxns
}
func removeSourcePrecedence(rbacIxns []*rbacIntention, intentionDefaultAction intentionAction) []*rbacIntention {
if len(rbacIxns) == 0 {
return nil
}
// Remove source precedence:
//
// First walk backwards and add each intention to all subsequent statements
// (via AND NOT $x).
//
// If it is L4 and has the same action as the default intention action then
// mark the rule itself for erasure.
numRetained := 0
for i := len(rbacIxns) - 1; i >= 0; i-- {
for j := i + 1; j < len(rbacIxns); j++ {
if rbacIxns[j].Skip {
continue
}
// [i] is the intention candidate that we are distributing
// [j] is the thing to maybe NOT [i] from
if ixnSourceMatches(rbacIxns[i].Source, rbacIxns[j].Source) {
rbacIxns[j].NotSources = append(rbacIxns[j].NotSources, rbacIxns[i].Source)
}
}
if rbacIxns[i].Action == intentionDefaultAction {
// Lower precedence intentions that match the default intention
// action are skipped, since they're handled by the default
// catch-all.
rbacIxns[i].Skip = true // mark for deletion
} else {
numRetained++
}
}
// At this point precedence doesn't matter for the source element.
// Remove skipped intentions and also compute the final Principals for each
// intention.
out := make([]*rbacIntention, 0, numRetained)
for _, rixn := range rbacIxns {
if rixn.Skip {
continue
}
rixn.ComputedPrincipal = rixn.FlattenPrincipal()
out = append(out, rixn)
}
return out
}
func removeIntentionPrecedence(rbacIxns []*rbacIntention, intentionDefaultAction intentionAction) []*rbacIntention {
// Remove source precedence. After this completes precedence doesn't matter
// between any two intentions.
rbacIxns = removeSourcePrecedence(rbacIxns, intentionDefaultAction)
for _, rbacIxn := range rbacIxns {
// Remove permission precedence. After this completes precedence
// doesn't matter between any two permissions on this intention.
rbacIxn.Permissions = removePermissionPrecedence(rbacIxn.Permissions, intentionDefaultAction)
}
return rbacIxns
}
func removePermissionPrecedence(perms []*rbacPermission, intentionDefaultAction intentionAction) []*rbacPermission {
if len(perms) == 0 {
return nil
}
// First walk backwards and add each permission to all subsequent
// statements (via AND NOT $x).
//
// If it has the same action as the default intention action then mark the
// permission itself for erasure.
numRetained := 0
for i := len(perms) - 1; i >= 0; i-- {
for j := i + 1; j < len(perms); j++ {
if perms[j].Skip {
continue
}
// [i] is the permission candidate that we are distributing
// [j] is the thing to maybe NOT [i] from
perms[j].NotPerms = append(
perms[j].NotPerms,
perms[i].Perm,
)
}
if perms[i].Action == intentionDefaultAction {
// Lower precedence permissions that match the default intention
// action are skipped, since they're handled by the default
// catch-all.
perms[i].Skip = true // mark for deletion
} else {
numRetained++
}
}
// Remove skipped permissions and also compute the final Permissions for each item.
out := make([]*rbacPermission, 0, numRetained)
for _, perm := range perms {
if perm.Skip {
continue
}
perm.ComputedPermission = perm.Flatten()
out = append(out, perm)
}
return out
}
func intentionToIntermediateRBACForm(ixn *structs.Intention, isHTTP bool) *rbacIntention {
rixn := &rbacIntention{
Source: ixn.SourceServiceName(),
Precedence: ixn.Precedence,
}
if len(ixn.Permissions) > 0 {
if isHTTP {
rixn.Action = intentionActionLayer7
rixn.Permissions = make([]*rbacPermission, 0, len(ixn.Permissions))
for _, perm := range ixn.Permissions {
rixn.Permissions = append(rixn.Permissions, &rbacPermission{
Definition: perm,
Action: intentionActionFromString(perm.Action),
Perm: convertPermission(perm),
})
}
} else {
// In case L7 intentions slip through to here, treat them as deny intentions.
rixn.Action = intentionActionDeny
}
} else {
rixn.Action = intentionActionFromString(ixn.Action)
}
return rixn
}
type intentionAction int
const (
intentionActionDeny intentionAction = iota
intentionActionAllow
intentionActionLayer7
)
func intentionActionFromBool(v bool) intentionAction {
if v {
return intentionActionAllow
} else {
return intentionActionDeny
}
}
func intentionActionFromString(s structs.IntentionAction) intentionAction {
if s == structs.IntentionActionAllow {
return intentionActionAllow
}
return intentionActionDeny
}
type rbacIntention struct {
Source structs.ServiceName
NotSources []structs.ServiceName
Action intentionAction
Permissions []*rbacPermission
Precedence int
// Skip is field used to indicate that this intention can be deleted in the
// final pass. Items marked as true should generally not escape the method
// that marked them.
Skip bool
ComputedPrincipal *envoyrbac.Principal
}
func (r *rbacIntention) FlattenPrincipal() *envoyrbac.Principal {
r.NotSources = simplifyNotSourceSlice(r.NotSources)
if len(r.NotSources) == 0 {
return idPrincipal(r.Source)
}
andIDs := make([]*envoyrbac.Principal, 0, len(r.NotSources)+1)
andIDs = append(andIDs, idPrincipal(r.Source))
for _, src := range r.NotSources {
andIDs = append(andIDs, notPrincipal(
idPrincipal(src),
))
}
return andPrincipals(andIDs)
}
type rbacPermission struct {
Definition *structs.IntentionPermission
Action intentionAction
Perm *envoyrbac.Permission
NotPerms []*envoyrbac.Permission
// Skip is field used to indicate that this permission can be deleted in
// the final pass. Items marked as true should generally not escape the
// method that marked them.
Skip bool
ComputedPermission *envoyrbac.Permission
}
func (p *rbacPermission) Flatten() *envoyrbac.Permission {
if len(p.NotPerms) == 0 {
return p.Perm
}
parts := make([]*envoyrbac.Permission, 0, len(p.NotPerms)+1)
parts = append(parts, p.Perm)
for _, notPerm := range p.NotPerms {
parts = append(parts, notPermission(notPerm))
}
return andPermissions(parts)
}
func simplifyNotSourceSlice(notSources []structs.ServiceName) []structs.ServiceName {
if len(notSources) <= 1 {
return notSources
}
// Collapse NotSources elements together if any element is a subset of
// another.
// Sort, keeping the least wildcarded elements first.
sort.SliceStable(notSources, func(i, j int) bool {
return countWild(notSources[i]) < countWild(notSources[j])
})
keep := make([]structs.ServiceName, 0, len(notSources))
for i := 0; i < len(notSources); i++ {
si := notSources[i]
remove := false
for j := i + 1; j < len(notSources); j++ {
sj := notSources[j]
if ixnSourceMatches(si, sj) {
remove = true
break
}
}
if !remove {
keep = append(keep, si)
}
}
return keep
}
// makeRBACRules translates Consul intentions into RBAC Policies for Envoy.
//
// Consul lets you define up to 9 different kinds of intentions that apply at
// different levels of precedence (this is limited to 4 if not using Consul
// Enterprise). Each intention in this flat list (sorted by precedence) can either
// be an allow rule or a deny rule. Heres a concrete example of this at work:
//
// intern/trusted-app => billing/payment-svc : ALLOW (prec=9)
// intern/* => billing/payment-svc : DENY (prec=8)
// */* => billing/payment-svc : ALLOW (prec=7)
// ::: ACL default policy ::: : DENY (prec=N/A)
//
// In contrast, Envoy lets you either configure a filter to be based on an
// allow-list or a deny-list based on the action attribute of the RBAC rules
// struct.
//
// On the surface it would seem that the configuration model of Consul
// intentions is incompatible with that of Envoys RBAC engine. For any given
// destination service Consuls model requires evaluating a list of rules and
// short circuiting later rules once an earlier rule matches. After a rule is
// found to match then we decide if it is allow/deny. Envoy on the other hand
// requires the rules to express all conditions to allow access or all conditions
// to deny access.
//
// Despite the surface incompatibility it is possible to marry these two
// models. For clarity Ill rewrite the earlier example intentions in an
// abbreviated form:
//
// A : ALLOW
// B : DENY
// C : ALLOW
// <default> : DENY
//
// 1. Given that the overall intention default is set to deny, we start by
// choosing to build an allow-list in Envoy (this is also the variant that I find
// easier to think about).
// 2. Next we traverse the list in precedence order (top down) and any DENY
// intentions are combined with later intentions using logical operations.
// 3. Now that all of the intentions result in the same action (allow) we have
// successfully removed precedence and we can express this in as a set of Envoy
// RBAC policies.
//
// After this the earlier A/B/C/default list becomes:
//
// A : ALLOW
// C AND NOT(B) : ALLOW
// <default> : DENY
//
// Which really is just an allow-list of [A, C AND NOT(B)]
func makeRBACRules(intentions structs.Intentions, intentionDefaultAllow bool, isHTTP bool) (*envoyrbac.RBAC, error) {
// Note that we DON'T explicitly validate the trust-domain matches ours.
//
// For now we don't validate the trust domain of the _destination_ at all.
// The RBAC policies below ignore the trust domain and it's implicit that
// the request is for the correct cluster. We might want to reconsider this
// later but plumbing in additional machinery to check the clusterID here
// is not really necessary for now unless the Envoys are badly configured.
// Our threat model _requires_ correctly configured and well behaved
// proxies given that they have ACLs to fetch certs and so can do whatever
// they want including not authorizing traffic at all or routing it do a
// different service than they auth'd against.
// TODO(banks,rb): Implement revocation list checking?
// First build up just the basic principal matches.
rbacIxns := intentionListToIntermediateRBACForm(intentions, isHTTP)
// Normalize: if we are in default-deny then all intentions must be allows and vice versa
intentionDefaultAction := intentionActionFromBool(intentionDefaultAllow)
var rbacAction envoyrbac.RBAC_Action
if intentionDefaultAllow {
// The RBAC policies deny access to principals. The rest is allowed.
// This is block-list style access control.
rbacAction = envoyrbac.RBAC_DENY
} else {
// The RBAC policies grant access to principals. The rest is denied.
// This is safe-list style access control. This is the default type.
rbacAction = envoyrbac.RBAC_ALLOW
}
// Remove source and permissions precedence.
rbacIxns = removeIntentionPrecedence(rbacIxns, intentionDefaultAction)
// For L4: we should generate one big Policy listing all Principals
// For L7: we should generate one Policy per Principal and list all of the Permissions
rbac := &envoyrbac.RBAC{
Action: rbacAction,
Policies: make(map[string]*envoyrbac.Policy),
}
var principalsL4 []*envoyrbac.Principal
for i, rbacIxn := range rbacIxns {
if len(rbacIxn.Permissions) > 0 {
if !isHTTP {
panic("invalid state: L7 permissions present for TCP service")
}
// For L7: we should generate one Policy per Principal and list all of the Permissions
policy := &envoyrbac.Policy{
Principals: []*envoyrbac.Principal{rbacIxn.ComputedPrincipal},
Permissions: make([]*envoyrbac.Permission, 0, len(rbacIxn.Permissions)),
}
for _, perm := range rbacIxn.Permissions {
policy.Permissions = append(policy.Permissions, perm.ComputedPermission)
}
rbac.Policies[fmt.Sprintf("consul-intentions-layer7-%d", i)] = policy
} else {
// For L4: we should generate one big Policy listing all Principals
principalsL4 = append(principalsL4, rbacIxn.ComputedPrincipal)
}
}
if len(principalsL4) > 0 {
rbac.Policies["consul-intentions-layer4"] = &envoyrbac.Policy{
Principals: principalsL4,
Permissions: []*envoyrbac.Permission{anyPermission()},
}
}
if len(rbac.Policies) == 0 {
rbac.Policies = nil
}
return rbac, nil
}
func removeSameSourceIntentions(intentions structs.Intentions) structs.Intentions {
if len(intentions) < 2 {
return intentions
}
var (
out = make(structs.Intentions, 0, len(intentions))
changed = false
seenSource = make(map[structs.ServiceName]struct{})
)
for _, ixn := range intentions {
sn := ixn.SourceServiceName()
if _, ok := seenSource[sn]; ok {
// A higher precedence intention already used this exact source
// definition with a different destination.
changed = true
continue
}
seenSource[sn] = struct{}{}
out = append(out, ixn)
}
if !changed {
return intentions
}
return out
}
// ixnSourceMatches deterines if the 'tester' service name is matched by the
// 'against' service name via wildcard rules.
//
// For instance:
// - (web, api) => false, because these have no wildcards
// - (web, *) => true, because "all services" includes "web"
// - (default/web, default/*) => true, because "all services in the default NS" includes "default/web"
// - (default/*, */*) => true, "any service in any NS" includes "all services in the default NS"
func ixnSourceMatches(tester, against structs.ServiceName) bool {
// We assume that we can't have the same intention twice before arriving
// here.
numWildTester := countWild(tester)
numWildAgainst := countWild(against)
if numWildTester == numWildAgainst {
return false
} else if numWildTester > numWildAgainst {
return false
}
matchesNS := tester.NamespaceOrDefault() == against.NamespaceOrDefault() || against.NamespaceOrDefault() == structs.WildcardSpecifier
matchesName := tester.Name == against.Name || against.Name == structs.WildcardSpecifier
return matchesNS && matchesName
}
// countWild counts the number of wildcard values in the given namespace and name.
func countWild(src structs.ServiceName) int {
// If NS is wildcard, it must be 2 since wildcards only follow exact
if src.NamespaceOrDefault() == structs.WildcardSpecifier {
return 2
}
// Same reasoning as above, a wildcard can only follow an exact value
// and an exact value cannot follow a wildcard, so if name is a wildcard
// we must have exactly one.
if src.Name == structs.WildcardSpecifier {
return 1
}
return 0
}
func andPrincipals(ids []*envoyrbac.Principal) *envoyrbac.Principal {
return &envoyrbac.Principal{
Identifier: &envoyrbac.Principal_AndIds{
AndIds: &envoyrbac.Principal_Set{
Ids: ids,
},
},
}
}
func notPrincipal(id *envoyrbac.Principal) *envoyrbac.Principal {
return &envoyrbac.Principal{
Identifier: &envoyrbac.Principal_NotId{
NotId: id,
},
}
}
func idPrincipal(src structs.ServiceName) *envoyrbac.Principal {
pattern := makeSpiffePattern(src.NamespaceOrDefault(), src.Name)
return &envoyrbac.Principal{
Identifier: &envoyrbac.Principal_Authenticated_{
Authenticated: &envoyrbac.Principal_Authenticated{
PrincipalName: &envoymatcher.StringMatcher{
MatchPattern: &envoymatcher.StringMatcher_SafeRegex{
SafeRegex: makeEnvoyRegexMatch(pattern),
},
},
},
},
}
}
func makeSpiffePattern(sourceNS, sourceName string) string {
const (
anyPath = `[^/]+`
spiffeTemplate = `^spiffe://%s/ns/%s/dc/%s/svc/%s$`
)
switch {
case sourceNS != structs.WildcardSpecifier && sourceName != structs.WildcardSpecifier:
return fmt.Sprintf(spiffeTemplate, anyPath, sourceNS, anyPath, sourceName)
case sourceNS != structs.WildcardSpecifier && sourceName == structs.WildcardSpecifier:
return fmt.Sprintf(spiffeTemplate, anyPath, sourceNS, anyPath, anyPath)
case sourceNS == structs.WildcardSpecifier && sourceName == structs.WildcardSpecifier:
return fmt.Sprintf(spiffeTemplate, anyPath, anyPath, anyPath, anyPath)
default:
panic(fmt.Sprintf("not possible to have a wildcarded namespace %q but an exact service %q", sourceNS, sourceName))
}
}
func anyPermission() *envoyrbac.Permission {
return &envoyrbac.Permission{
Rule: &envoyrbac.Permission_Any{Any: true},
}
}
func convertPermission(perm *structs.IntentionPermission) *envoyrbac.Permission {
// NOTE: this does not do anything with perm.Action
if perm.HTTP == nil {
return anyPermission()
}
var parts []*envoyrbac.Permission
switch {
case perm.HTTP.PathExact != "":
parts = append(parts, &envoyrbac.Permission{
Rule: &envoyrbac.Permission_UrlPath{
UrlPath: &envoymatcher.PathMatcher{
Rule: &envoymatcher.PathMatcher_Path{
Path: &envoymatcher.StringMatcher{
MatchPattern: &envoymatcher.StringMatcher_Exact{
Exact: perm.HTTP.PathExact,
},
},
},
},
},
})
case perm.HTTP.PathPrefix != "":
parts = append(parts, &envoyrbac.Permission{
Rule: &envoyrbac.Permission_UrlPath{
UrlPath: &envoymatcher.PathMatcher{
Rule: &envoymatcher.PathMatcher_Path{
Path: &envoymatcher.StringMatcher{
MatchPattern: &envoymatcher.StringMatcher_Prefix{
Prefix: perm.HTTP.PathPrefix,
},
},
},
},
},
})
case perm.HTTP.PathRegex != "":
parts = append(parts, &envoyrbac.Permission{
Rule: &envoyrbac.Permission_UrlPath{
UrlPath: &envoymatcher.PathMatcher{
Rule: &envoymatcher.PathMatcher_Path{
Path: &envoymatcher.StringMatcher{
MatchPattern: &envoymatcher.StringMatcher_SafeRegex{
SafeRegex: makeEnvoyRegexMatch(perm.HTTP.PathRegex),
},
},
},
},
},
})
}
for _, hdr := range perm.HTTP.Header {
eh := &envoyroute.HeaderMatcher{
Name: hdr.Name,
}
switch {
case hdr.Exact != "":
eh.HeaderMatchSpecifier = &envoyroute.HeaderMatcher_ExactMatch{
ExactMatch: hdr.Exact,
}
case hdr.Regex != "":
eh.HeaderMatchSpecifier = &envoyroute.HeaderMatcher_SafeRegexMatch{
SafeRegexMatch: makeEnvoyRegexMatch(hdr.Regex),
}
case hdr.Prefix != "":
eh.HeaderMatchSpecifier = &envoyroute.HeaderMatcher_PrefixMatch{
PrefixMatch: hdr.Prefix,
}
case hdr.Suffix != "":
eh.HeaderMatchSpecifier = &envoyroute.HeaderMatcher_SuffixMatch{
SuffixMatch: hdr.Suffix,
}
case hdr.Present:
eh.HeaderMatchSpecifier = &envoyroute.HeaderMatcher_PresentMatch{
PresentMatch: true,
}
default:
continue // skip this impossible situation
}
if hdr.Invert {
eh.InvertMatch = true
}
parts = append(parts, &envoyrbac.Permission{
Rule: &envoyrbac.Permission_Header{
Header: eh,
},
})
}
if len(perm.HTTP.Methods) > 0 {
methodHeaderRegex := strings.Join(perm.HTTP.Methods, "|")
eh := &envoyroute.HeaderMatcher{
Name: ":method",
HeaderMatchSpecifier: &envoyroute.HeaderMatcher_SafeRegexMatch{
SafeRegexMatch: makeEnvoyRegexMatch(methodHeaderRegex),
},
}
parts = append(parts, &envoyrbac.Permission{
Rule: &envoyrbac.Permission_Header{
Header: eh,
},
})
}
// NOTE: if for some reason we errantly allow a permission to be defined
// with a body of "http{}" then we'll end up treating that like "ANY" here.
return andPermissions(parts)
}
func notPermission(perm *envoyrbac.Permission) *envoyrbac.Permission {
return &envoyrbac.Permission{
Rule: &envoyrbac.Permission_NotRule{NotRule: perm},
}
}
func andPermissions(perms []*envoyrbac.Permission) *envoyrbac.Permission {
switch len(perms) {
case 0:
return anyPermission()
case 1:
return perms[0]
default:
return &envoyrbac.Permission{
Rule: &envoyrbac.Permission_AndRules{
AndRules: &envoyrbac.Permission_Set{
Rules: perms,
},
},
}
}
}