// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package xds import ( "fmt" "sort" "strings" envoy_listener_v3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" envoy_rbac_v3 "github.com/envoyproxy/go-control-plane/envoy/config/rbac/v3" envoy_route_v3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" envoy_http_header_to_meta_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/header_to_metadata/v3" envoy_http_rbac_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/rbac/v3" envoy_http_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" envoy_network_rbac_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/rbac/v3" envoy_matcher_v3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" "github.com/hashicorp/consul/agent/connect" "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/agent/xds/response" "github.com/hashicorp/consul/proto/private/pbpeering" ) const ( envoyHTTPRBACFilterKey = "envoy.filters.http.rbac" envoyNetworkRBACFilterKey = "envoy.filters.network.rbac" ) func makeRBACNetworkFilter( intentions structs.SimplifiedIntentions, intentionDefaultAllow bool, localInfo rbacLocalInfo, peerTrustBundles []*pbpeering.PeeringTrustBundle, ) (*envoy_listener_v3.Filter, error) { rules, err := makeRBACRules(intentions, intentionDefaultAllow, localInfo, false, peerTrustBundles, nil) if err != nil { return nil, err } cfg := &envoy_network_rbac_v3.RBAC{ StatPrefix: "connect_authz", Rules: rules, } return makeFilter(envoyNetworkRBACFilterKey, cfg) } func makeRBACHTTPFilter( intentions structs.SimplifiedIntentions, intentionDefaultAllow bool, localInfo rbacLocalInfo, peerTrustBundles []*pbpeering.PeeringTrustBundle, providerMap map[string]*structs.JWTProviderConfigEntry, ) (*envoy_http_v3.HttpFilter, error) { rules, err := makeRBACRules(intentions, intentionDefaultAllow, localInfo, true, peerTrustBundles, providerMap) if err != nil { return nil, err } cfg := &envoy_http_rbac_v3.RBAC{ Rules: rules, } return makeEnvoyHTTPFilter(envoyHTTPRBACFilterKey, cfg) } func intentionListToIntermediateRBACForm( intentions structs.SimplifiedIntentions, localInfo rbacLocalInfo, isHTTP bool, trustBundlesByPeer map[string]*pbpeering.PeeringTrustBundle, providerMap map[string]*structs.JWTProviderConfigEntry, ) ([]*rbacIntention, error) { 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 { // trustBundle is only applicable to imported services trustBundle, ok := trustBundlesByPeer[ixn.SourcePeer] if ixn.SourcePeer != "" && !ok { // If the intention defines a source peer, we expect to // see a trust bundle. Otherwise the config snapshot may // not have yet received the bundles and we fail silently continue } rixn, err := intentionToIntermediateRBACForm(ixn, localInfo, isHTTP, trustBundle, providerMap) if err != nil { return nil, err } rbacIxns = append(rbacIxns, rixn) } return rbacIxns, nil } func removeSourcePrecedence(rbacIxns []*rbacIntention, intentionDefaultAction intentionAction, localInfo rbacLocalInfo) []*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(localInfo) out = append(out, rixn) } return out } func removeIntentionPrecedence(rbacIxns []*rbacIntention, intentionDefaultAction intentionAction, localInfo rbacLocalInfo) []*rbacIntention { // Remove source precedence. After this completes precedence doesn't matter // between any two intentions. rbacIxns = removeSourcePrecedence(rbacIxns, intentionDefaultAction, localInfo) numRetained := 0 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) if rbacIxn.Action == intentionActionLayer7 && len(rbacIxn.Permissions) == 0 { // All of the permissions must have had the default action type and // were removed. Mark this for removal below. rbacIxn.Skip = true } else { numRetained++ } } if numRetained == len(rbacIxns) { return rbacIxns } // We previously used the absence of permissions (above) as a signal to // mark the entire intention for removal. Now do the deletions. out := make([]*rbacIntention, 0, numRetained) for _, rixn := range rbacIxns { if !rixn.Skip { out = append(out, rixn) } } return out } 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, localInfo rbacLocalInfo, isHTTP bool, bundle *pbpeering.PeeringTrustBundle, providerMap map[string]*structs.JWTProviderConfigEntry, ) (*rbacIntention, error) { rixn := &rbacIntention{ Source: rbacService{ ServiceName: ixn.SourceServiceName(), Peer: ixn.SourcePeer, TrustDomain: localInfo.trustDomain, }, Precedence: ixn.Precedence, } // imported services will have addition metadata used to override SpiffeID creation if bundle != nil { rixn.Source.ExportedPartition = bundle.ExportedPartition rixn.Source.TrustDomain = bundle.TrustDomain } if isHTTP && ixn.JWT != nil { var jwts []*JWTInfo for _, prov := range ixn.JWT.Providers { jwtProvider, ok := providerMap[prov.Name] if !ok { return nil, fmt.Errorf("provider specified in intention does not exist. Provider name: %s", prov.Name) } jwts = append(jwts, newJWTInfo(prov, jwtProvider)) } rixn.jwtInfos = jwts } if len(ixn.Permissions) > 0 { if isHTTP { rixn.Action = intentionActionLayer7 rixn.Permissions = make([]*rbacPermission, 0, len(ixn.Permissions)) for _, perm := range ixn.Permissions { rbacPerm := rbacPermission{ Definition: perm, Action: intentionActionFromString(perm.Action), Perm: convertPermission(perm), } if perm.JWT != nil { var jwts []*JWTInfo for _, prov := range perm.JWT.Providers { jwtProvider, ok := providerMap[prov.Name] if !ok { return nil, fmt.Errorf("provider specified in intention does not exist. Provider name: %s", prov.Name) } jwts = append(jwts, newJWTInfo(prov, jwtProvider)) } if len(jwts) > 0 { rbacPerm.jwtInfos = jwts } } rixn.Permissions = append(rixn.Permissions, &rbacPerm) } } 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, nil } func newJWTInfo(p *structs.IntentionJWTProvider, ce *structs.JWTProviderConfigEntry) *JWTInfo { return &JWTInfo{ Provider: p, Issuer: ce.Issuer, } } type intentionAction int type JWTInfo struct { // Provider issuer // this information is coming from the config entry Issuer string // Provider is the intention provider Provider *structs.IntentionJWTProvider } 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 rbacService struct { structs.ServiceName // Peer, ExportedPartition, and TrustDomain are // only applicable to imported services and are // used to override SPIFFEID fields. Peer string ExportedPartition string TrustDomain string } type rbacIntention struct { Source rbacService NotSources []rbacService Action intentionAction Permissions []*rbacPermission Precedence int // JWTInfo is used to track intentions' JWT information // This information is used to update HTTP filters for // JWT Payload & claims validation jwtInfos []*JWTInfo // 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 *envoy_rbac_v3.Principal } func (r *rbacIntention) FlattenPrincipal(localInfo rbacLocalInfo) *envoy_rbac_v3.Principal { var principal *envoy_rbac_v3.Principal if !localInfo.expectXFCC { principal = r.flattenPrincipalFromCert() } else if r.Source.Peer == "" { // NOTE: ixnSourceMatches should enforce that all of Source and NotSources // are peered or not-peered, so we only need to look at the Source element. principal = r.flattenPrincipalFromCert() // intention is not relevant to peering } else { // If this intention is an L7 peered one, then it is exclusively resolvable // using XFCC, rather than the TLS SAN field. fromXFCC := r.flattenPrincipalFromXFCC() // Use of the XFCC one is gated on coming directly from our own gateways. gwIDPattern := makeSpiffeMeshGatewayPattern(localInfo.trustDomain, localInfo.partition) principal = andPrincipals([]*envoy_rbac_v3.Principal{ authenticatedPatternPrincipal(gwIDPattern), fromXFCC, }) } if len(r.jwtInfos) == 0 { return principal } return addJWTPrincipal(principal, r.jwtInfos) } func (r *rbacIntention) flattenPrincipalFromCert() *envoy_rbac_v3.Principal { r.NotSources = simplifyNotSourceSlice(r.NotSources) if len(r.NotSources) == 0 { return idPrincipal(r.Source) } andIDs := make([]*envoy_rbac_v3.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) } func (r *rbacIntention) flattenPrincipalFromXFCC() *envoy_rbac_v3.Principal { r.NotSources = simplifyNotSourceSlice(r.NotSources) if len(r.NotSources) == 0 { return xfccPrincipal(r.Source) } andIDs := make([]*envoy_rbac_v3.Principal, 0, len(r.NotSources)+1) andIDs = append(andIDs, xfccPrincipal(r.Source)) for _, src := range r.NotSources { andIDs = append(andIDs, notPrincipal( xfccPrincipal(src), )) } return andPrincipals(andIDs) } type rbacPermission struct { Definition *structs.IntentionPermission Action intentionAction Perm *envoy_rbac_v3.Permission NotPerms []*envoy_rbac_v3.Permission // JWTInfo is used to track intentions' JWT information // This information is used to update HTTP filters for // JWT Payload & claims validation jwtInfos []*JWTInfo // 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 *envoy_rbac_v3.Permission } // Flatten ensure the permission rules, not-rules, and jwt validation rules are merged into a single computed permission. // // Details on JWTInfo section: // For each JWTInfo (AKA provider required), this builds 1 single permission that validates that the jwt has // the right issuer (`iss`) field and validates the claims (if any). // // After generating a single permission per info, it combines all the info permissions into a single OrPermission. // This orPermission is then attached to initial computed permission for jwt payload and claims validation. func (p *rbacPermission) Flatten() *envoy_rbac_v3.Permission { computedPermission := p.Perm if len(p.NotPerms) == 0 && len(p.jwtInfos) == 0 { return computedPermission } if len(p.NotPerms) != 0 { parts := make([]*envoy_rbac_v3.Permission, 0, len(p.NotPerms)+1) parts = append(parts, p.Perm) for _, notPerm := range p.NotPerms { parts = append(parts, notPermission(notPerm)) } computedPermission = andPermissions(parts) } if len(p.jwtInfos) == 0 { return computedPermission } var jwtPerms []*envoy_rbac_v3.Permission for _, info := range p.jwtInfos { payloadKey := buildPayloadInMetadataKey(info.Provider.Name) claimsPermission := jwtInfosToPermission(info.Provider.VerifyClaims, payloadKey) issuerPermission := segmentToPermission(pathToSegments([]string{"iss"}, payloadKey), info.Issuer) perm := andPermissions([]*envoy_rbac_v3.Permission{ issuerPermission, claimsPermission, }) jwtPerms = append(jwtPerms, perm) } jwtPerm := orPermissions(jwtPerms) return andPermissions([]*envoy_rbac_v3.Permission{computedPermission, jwtPerm}) } // simplifyNotSourceSlice will collapse NotSources elements together if any element is // a subset of another. // For example "default/web" is a subset of "default/*" because it is covered by the wildcard. func simplifyNotSourceSlice(notSources []rbacService) []rbacService { if len(notSources) <= 1 { return notSources } // Sort, keeping the least wildcarded elements first. // More specific elements have a higher precedence over more wildcarded elements. sort.SliceStable(notSources, func(i, j int) bool { return countWild(notSources[i]) < countWild(notSources[j]) }) keep := make([]rbacService, 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 } type rbacLocalInfo struct { trustDomain string datacenter string partition string expectXFCC bool } // 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. Here’s 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 Envoy’s RBAC engine. For any given // destination service Consul’s 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 I’ll rewrite the earlier example intentions in an // abbreviated form: // // A : ALLOW // B : DENY // C : ALLOW // : 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 // : DENY // // Which really is just an allow-list of [A, C AND NOT(B)] func makeRBACRules( intentions structs.SimplifiedIntentions, intentionDefaultAllow bool, localInfo rbacLocalInfo, isHTTP bool, peerTrustBundles []*pbpeering.PeeringTrustBundle, providerMap map[string]*structs.JWTProviderConfigEntry, ) (*envoy_rbac_v3.RBAC, error) { // TODO(banks,rb): Implement revocation list checking? // TODO(peering): mkeeler asked that these maps come from proxycfg instead of // being constructed in xds to save memory allocation and gc pressure. Low priority. trustBundlesByPeer := make(map[string]*pbpeering.PeeringTrustBundle, len(peerTrustBundles)) for _, ptb := range peerTrustBundles { trustBundlesByPeer[ptb.PeerName] = ptb } if isHTTP && len(peerTrustBundles) > 0 { for _, ixn := range intentions { if ixn.SourcePeer != "" { localInfo.expectXFCC = true break } } } // First build up just the basic principal matches. rbacIxns, err := intentionListToIntermediateRBACForm(intentions, localInfo, isHTTP, trustBundlesByPeer, providerMap) if err != nil { return nil, err } // Normalize: if we are in default-deny then all intentions must be allows and vice versa intentionDefaultAction := intentionActionFromBool(intentionDefaultAllow) var rbacAction envoy_rbac_v3.RBAC_Action if intentionDefaultAllow { // The RBAC policies deny access to principals. The rest is allowed. // This is block-list style access control. rbacAction = envoy_rbac_v3.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 = envoy_rbac_v3.RBAC_ALLOW } // Remove source and permissions precedence. rbacIxns = removeIntentionPrecedence(rbacIxns, intentionDefaultAction, localInfo) // 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 := &envoy_rbac_v3.RBAC{ Action: rbacAction, Policies: make(map[string]*envoy_rbac_v3.Policy), } var principalsL4 []*envoy_rbac_v3.Principal for i, rbacIxn := range rbacIxns { if rbacIxn.Action == intentionActionLayer7 { if len(rbacIxn.Permissions) == 0 { panic("invalid state: L7 intention has no permissions") } if !isHTTP { panic("invalid state: L7 permissions present for TCP service") } rbacPrincipals := optimizePrincipals([]*envoy_rbac_v3.Principal{rbacIxn.ComputedPrincipal}) // For L7: we should generate one Policy per Principal and list all of the Permissions policy := &envoy_rbac_v3.Policy{ Principals: rbacPrincipals, Permissions: make([]*envoy_rbac_v3.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"] = &envoy_rbac_v3.Policy{ Principals: optimizePrincipals(principalsL4), Permissions: []*envoy_rbac_v3.Permission{anyPermission()}, } } if len(rbac.Policies) == 0 { rbac.Policies = nil } return rbac, nil } // addJWTPrincipal ensure the passed RBAC/Network principal is associated with // a JWT principal when JWTs validation is required. // // For each jwtInfo, this builds a first principal that validates that the jwt has the right issuer (`iss`). // It collects all the claims principal and combines them into a single principal using jwtClaimsToPrincipals. // It then combines the issuer principal and the claims principal into a single principal. // // After generating a single principal per info, it combines all the info principals into a single jwt OrPrincipal. // This orPrincipal is then attached to the RBAC/NETWORK principal for jwt payload validation. func addJWTPrincipal(principal *envoy_rbac_v3.Principal, infos []*JWTInfo) *envoy_rbac_v3.Principal { if len(infos) == 0 { return principal } jwtPrincipals := make([]*envoy_rbac_v3.Principal, 0, len(infos)) for _, info := range infos { payloadKey := buildPayloadInMetadataKey(info.Provider.Name) // build jwt provider issuer principal segments := pathToSegments([]string{"iss"}, payloadKey) p := segmentToPrincipal(segments, info.Issuer) // add jwt provider claims principal if any if cp := jwtClaimsToPrincipals(info.Provider.VerifyClaims, payloadKey); cp != nil { p = andPrincipals([]*envoy_rbac_v3.Principal{p, cp}) } jwtPrincipals = append(jwtPrincipals, p) } // make jwt principals into 1 single principal jwtFinalPrincipal := orPrincipals(jwtPrincipals) if principal == nil { return jwtFinalPrincipal } return andPrincipals([]*envoy_rbac_v3.Principal{principal, jwtFinalPrincipal}) } func jwtClaimsToPrincipals(claims []*structs.IntentionJWTClaimVerification, payloadkey string) *envoy_rbac_v3.Principal { ps := make([]*envoy_rbac_v3.Principal, 0) for _, claim := range claims { ps = append(ps, jwtClaimToPrincipal(claim, payloadkey)) } switch len(ps) { case 0: return nil case 1: return ps[0] default: return andPrincipals(ps) } } // jwtClaimToPrincipal takes in a payloadkey which is the metadata key. This key is generated by using provider name // and a jwt_payload prefix. See buildPayloadInMetadataKey in agent/xds/jwt_authn.go // // This uniquely generated payloadKey is the first segment in the path to validate the JWT claims. The subsequent segments // come from the Path included in the IntentionJWTClaimVerification param. func jwtClaimToPrincipal(c *structs.IntentionJWTClaimVerification, payloadKey string) *envoy_rbac_v3.Principal { segments := pathToSegments(c.Path, payloadKey) return segmentToPrincipal(segments, c.Value) } func segmentToPrincipal(segments []*envoy_matcher_v3.MetadataMatcher_PathSegment, v string) *envoy_rbac_v3.Principal { return &envoy_rbac_v3.Principal{ Identifier: &envoy_rbac_v3.Principal_Metadata{ Metadata: &envoy_matcher_v3.MetadataMatcher{ Filter: jwtEnvoyFilter, Path: segments, Value: &envoy_matcher_v3.ValueMatcher{ MatchPattern: &envoy_matcher_v3.ValueMatcher_StringMatch{ StringMatch: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_Exact{ Exact: v, }, }, }, }, }, }, } } func jwtInfosToPermission(claims []*structs.IntentionJWTClaimVerification, payloadkey string) *envoy_rbac_v3.Permission { ps := make([]*envoy_rbac_v3.Permission, 0, len(claims)) for _, claim := range claims { ps = append(ps, jwtClaimToPermission(claim, payloadkey)) } return andPermissions(ps) } func jwtClaimToPermission(c *structs.IntentionJWTClaimVerification, payloadKey string) *envoy_rbac_v3.Permission { segments := pathToSegments(c.Path, payloadKey) return segmentToPermission(segments, c.Value) } func segmentToPermission(segments []*envoy_matcher_v3.MetadataMatcher_PathSegment, v string) *envoy_rbac_v3.Permission { return &envoy_rbac_v3.Permission{ Rule: &envoy_rbac_v3.Permission_Metadata{ Metadata: &envoy_matcher_v3.MetadataMatcher{ Filter: jwtEnvoyFilter, Path: segments, Value: &envoy_matcher_v3.ValueMatcher{ MatchPattern: &envoy_matcher_v3.ValueMatcher_StringMatch{ StringMatch: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_Exact{ Exact: v, }, }, }, }, }, }, } } // pathToSegments generates an array of MetadataMatcher_PathSegment that starts with the payloadkey // and is followed by all existing strings in the path. // // eg. calling: pathToSegments([]string{"perms", "roles"}, "jwt_payload_okta") should return the following: // // []*envoy_matcher_v3.MetadataMatcher_PathSegment{ // { // Segment: &envoy_matcher_v3.MetadataMatcher_PathSegment_Key{Key: "jwt_payload_okta"}, // }, // { // Segment: &envoy_matcher_v3.MetadataMatcher_PathSegment_Key{Key: "perms"}, // }, // { // Segment: &envoy_matcher_v3.MetadataMatcher_PathSegment_Key{Key: "roles"}, // }, // }, func pathToSegments(paths []string, payloadKey string) []*envoy_matcher_v3.MetadataMatcher_PathSegment { segments := make([]*envoy_matcher_v3.MetadataMatcher_PathSegment, 0, len(paths)) segments = append(segments, makeSegment(payloadKey)) for _, p := range paths { segments = append(segments, makeSegment(p)) } return segments } func makeSegment(key string) *envoy_matcher_v3.MetadataMatcher_PathSegment { return &envoy_matcher_v3.MetadataMatcher_PathSegment{ Segment: &envoy_matcher_v3.MetadataMatcher_PathSegment_Key{Key: key}, } } func optimizePrincipals(orig []*envoy_rbac_v3.Principal) []*envoy_rbac_v3.Principal { // If they are all ORs, then OR them together. var orIds []*envoy_rbac_v3.Principal for _, p := range orig { or, ok := p.Identifier.(*envoy_rbac_v3.Principal_OrIds) if !ok { return orig } // In practice, you can't hit this // Only JWTs (HTTP-only) generate orPrinciples, but optimizePrinciples is only called // against the combined list of principles for L4 intentions. orIds = append(orIds, or.OrIds.Ids...) } return []*envoy_rbac_v3.Principal{orPrincipals(orIds)} } // removeSameSourceIntentions will iterate over intentions and remove any lower precedence // intentions that share the same source. Intentions are sorted by descending precedence // so once a source has been seen, additional intentions with the same source can be dropped. // // Example for the default/web service: // input: [(backend/* -> default/web), (backend/* -> default/*)] // output: [(backend/* -> default/web)] // // (backend/* -> default/*) was dropped because it is already known that any service // in the backend namespace can target default/web. func removeSameSourceIntentions(intentions structs.SimplifiedIntentions) structs.SimplifiedIntentions { if len(intentions) < 2 { return intentions } var ( out = make(structs.SimplifiedIntentions, 0, len(intentions)) changed = false seenSource = make(map[structs.PeeredServiceName]struct{}) ) for _, ixn := range intentions { psn := structs.PeeredServiceName{ ServiceName: ixn.SourceServiceName(), Peer: ixn.SourcePeer, } if _, ok := seenSource[psn]; ok { // A higher precedence intention already used this exact source // definition with a different destination. changed = true continue } seenSource[psn] = struct{}{} out = append(out, ixn) } if !changed { return intentions } return out } // ixnSourceMatches determines 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" // - (default/default/*, other/*/*) => false, "any service in "other" partition" does NOT include services in the default partition" // // Peer and partition must be exact names and cannot be compared with wildcards. func ixnSourceMatches(tester, against rbacService) 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 } matchesAP := tester.PartitionOrDefault() == against.PartitionOrDefault() matchesPeer := tester.Peer == against.Peer matchesNS := tester.NamespaceOrDefault() == against.NamespaceOrDefault() || against.NamespaceOrDefault() == structs.WildcardSpecifier matchesName := tester.Name == against.Name || against.Name == structs.WildcardSpecifier return matchesAP && matchesPeer && matchesNS && matchesName } // countWild counts the number of wildcard values in the given namespace and name. func countWild(src rbacService) int { // If Partition is wildcard, panic because it's not supported if src.PartitionOrDefault() == structs.WildcardSpecifier { panic("invalid state: intention references wildcard partition") } if src.Peer == structs.WildcardSpecifier { panic("invalid state: intention references wildcard peer") } // 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 []*envoy_rbac_v3.Principal) *envoy_rbac_v3.Principal { switch len(ids) { case 1: return ids[0] default: return &envoy_rbac_v3.Principal{ Identifier: &envoy_rbac_v3.Principal_AndIds{ AndIds: &envoy_rbac_v3.Principal_Set{ Ids: ids, }, }, } } } func orPrincipals(ids []*envoy_rbac_v3.Principal) *envoy_rbac_v3.Principal { switch len(ids) { case 1: return ids[0] default: return &envoy_rbac_v3.Principal{ Identifier: &envoy_rbac_v3.Principal_OrIds{ OrIds: &envoy_rbac_v3.Principal_Set{ Ids: ids, }, }, } } } func notPrincipal(id *envoy_rbac_v3.Principal) *envoy_rbac_v3.Principal { return &envoy_rbac_v3.Principal{ Identifier: &envoy_rbac_v3.Principal_NotId{ NotId: id, }, } } func idPrincipal(src rbacService) *envoy_rbac_v3.Principal { pattern := makeSpiffePattern(src) return authenticatedPatternPrincipal(pattern) } func authenticatedPatternPrincipal(pattern string) *envoy_rbac_v3.Principal { return &envoy_rbac_v3.Principal{ Identifier: &envoy_rbac_v3.Principal_Authenticated_{ Authenticated: &envoy_rbac_v3.Principal_Authenticated{ PrincipalName: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_SafeRegex{ SafeRegex: response.MakeEnvoyRegexMatch(pattern), }, }, }, }, } } func xfccPrincipal(src rbacService) *envoy_rbac_v3.Principal { // Same match we normally would use. idPattern := makeSpiffePattern(src) // Remove the leading ^ and trailing $. idPattern = idPattern[1 : len(idPattern)-1] // Anchor to the first XFCC component pattern := `^[^,]+;URI=` + idPattern + `(?:,.*)?$` // By=spiffe://8c7db6d3-e4ee-aa8c-488c-dbedd3772b78.consul/gateway/mesh/dc/dc2; // Hash=2a2db78ac351a05854a0abd350631bf98cc0eb827d21f4ed5935ccd287779eb6; // Cert="-----BEGIN%20CERTIFICATE-----"; // Chain="-----BEGIN%20CERTIFICATE-----"; // Subject=""; // URI=spiffe://5583c38e-c1c0-fd1e-2079-170bb2f396ad.consul/ns/default/dc/dc1/svc/pong, return &envoy_rbac_v3.Principal{ Identifier: &envoy_rbac_v3.Principal_Header{ Header: &envoy_route_v3.HeaderMatcher{ Name: "x-forwarded-client-cert", HeaderMatchSpecifier: &envoy_route_v3.HeaderMatcher_StringMatch{ StringMatch: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_SafeRegex{ SafeRegex: response.MakeEnvoyRegexMatch(pattern), }, }, }, }, }, } } const ( anyPath = `[^/]+` trustDomain = anyPath + "." + anyPath ) // downstreamServiceIdentityMatcher needs to match XFCC headers in two cases: // 1. Requests to cluster peered services through a mesh gateway. In this case, the XFCC header looks like the following (I added a new line after each ; for readability) // By=spiffe://950df996-caef-ddef-ec5f-8d18a153b7b2.consul/gateway/mesh/dc/alpha; // Hash=...; // Cert=...; // Chain=...; // Subject=""; // URI=spiffe://c7e1d24a-eed8-10a3-286a-52bdb6b6a6fd.consul/ns/default/dc/primary/svc/s1,By=spiffe://950df996-caef-ddef-ec5f-8d18a153b7b2.consul/ns/default/dc/alpha/svc/s2; // Hash=...; // Cert=...; // Chain=...; // Subject=""; // URI=spiffe://950df996-caef-ddef-ec5f-8d18a153b7b2.consul/gateway/mesh/dc/alpha // // 2. Requests directly to another service // By=spiffe://ae9dbea8-c1dd-7356-b211-c564f7917100.consul/ns/default/dc/primary/svc/s2; // Hash=396218588ebc1655d32a49b68cedd6b66b9de7b3d69d0c0451bc5818132377d0; // Cert=...; // Chain=...; // Subject=""; // URI=spiffe://ae9dbea8-c1dd-7356-b211-c564f7917100.consul/ns/default/dc/primary/svc/s1 // // In either case, the regex matches the downstream service's spiffe id because mesh gateways use a different spiffe id format. // Envoy requires us to include the trailing and leading .* to properly extract the properly submatch. const downstreamServiceIdentityMatcher = ".*URI=spiffe://(" + trustDomain + ")(?:/ap/(" + anyPath + "))?/ns/(" + anyPath + ")/dc/(" + anyPath + ")/svc/([^/;,]+).*" func parseXFCCToDynamicMetaHTTPFilter() (*envoy_http_v3.HttpFilter, error) { var rules []*envoy_http_header_to_meta_v3.Config_Rule fields := []struct { name string sub string }{ { name: "trust-domain", sub: `\1`, }, { name: "partition", sub: `\2`, }, { name: "namespace", sub: `\3`, }, { name: "datacenter", sub: `\4`, }, { name: "service", sub: `\5`, }, } for _, f := range fields { rules = append(rules, &envoy_http_header_to_meta_v3.Config_Rule{ Header: "x-forwarded-client-cert", OnHeaderPresent: &envoy_http_header_to_meta_v3.Config_KeyValuePair{ MetadataNamespace: "consul", Key: f.name, RegexValueRewrite: &envoy_matcher_v3.RegexMatchAndSubstitute{ Pattern: &envoy_matcher_v3.RegexMatcher{ Regex: downstreamServiceIdentityMatcher, }, Substitution: f.sub, }, }, }) } cfg := &envoy_http_header_to_meta_v3.Config{RequestRules: rules} return makeEnvoyHTTPFilter("envoy.filters.http.header_to_metadata", cfg) } func makeSpiffePattern(src rbacService) string { var ( host = src.TrustDomain ap = src.PartitionOrDefault() ns = src.NamespaceOrDefault() svc = src.Name ) // Validate proper wildcarding if ns == structs.WildcardSpecifier && svc != structs.WildcardSpecifier { panic(fmt.Sprintf("not possible to have a wildcarded namespace %q but an exact service %q", ns, svc)) } if ap == structs.WildcardSpecifier { panic("not possible to have a wildcarded source partition") } if src.Peer == structs.WildcardSpecifier { panic("not possible to have a wildcarded source peer") } // Match on any namespace or service if it is a wildcard, or on a specific value otherwise. if ns == structs.WildcardSpecifier { ns = anyPath } if svc == structs.WildcardSpecifier { svc = anyPath } // If service is imported from a peer, the SpiffeID must // refer to its remote partition and trust domain. if src.Peer != "" { ap = src.ExportedPartition host = src.TrustDomain } id := connect.SpiffeIDService{ Namespace: ns, Service: svc, Host: host, // Datacenter is not verified by RBAC, so we match on any value. Datacenter: anyPath, // Partition can only ever be an exact value. Partition: ap, } return fmt.Sprintf(`^%s://%s%s$`, id.URI().Scheme, id.Host, id.URI().Path) } func makeSpiffeMeshGatewayPattern(gwTrustDomain, gwPartition string) string { id := connect.SpiffeIDMeshGateway{ Host: gwTrustDomain, Partition: gwPartition, // Datacenter is not verified by RBAC, so we match on any value. Datacenter: anyPath, } return fmt.Sprintf(`^%s://%s%s$`, id.URI().Scheme, id.Host, id.URI().Path) } func anyPermission() *envoy_rbac_v3.Permission { return &envoy_rbac_v3.Permission{ Rule: &envoy_rbac_v3.Permission_Any{Any: true}, } } func convertPermission(perm *structs.IntentionPermission) *envoy_rbac_v3.Permission { // NOTE: this does not do anything with perm.Action if perm.HTTP == nil { return anyPermission() } var parts []*envoy_rbac_v3.Permission switch { case perm.HTTP.PathExact != "": parts = append(parts, &envoy_rbac_v3.Permission{ Rule: &envoy_rbac_v3.Permission_UrlPath{ UrlPath: &envoy_matcher_v3.PathMatcher{ Rule: &envoy_matcher_v3.PathMatcher_Path{ Path: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_Exact{ Exact: perm.HTTP.PathExact, }, }, }, }, }, }) case perm.HTTP.PathPrefix != "": parts = append(parts, &envoy_rbac_v3.Permission{ Rule: &envoy_rbac_v3.Permission_UrlPath{ UrlPath: &envoy_matcher_v3.PathMatcher{ Rule: &envoy_matcher_v3.PathMatcher_Path{ Path: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_Prefix{ Prefix: perm.HTTP.PathPrefix, }, }, }, }, }, }) case perm.HTTP.PathRegex != "": parts = append(parts, &envoy_rbac_v3.Permission{ Rule: &envoy_rbac_v3.Permission_UrlPath{ UrlPath: &envoy_matcher_v3.PathMatcher{ Rule: &envoy_matcher_v3.PathMatcher_Path{ Path: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_SafeRegex{ SafeRegex: response.MakeEnvoyRegexMatch(perm.HTTP.PathRegex), }, }, }, }, }, }) } for _, hdr := range perm.HTTP.Header { eh := &envoy_route_v3.HeaderMatcher{ Name: hdr.Name, } switch { case hdr.Exact != "": eh.HeaderMatchSpecifier = &envoy_route_v3.HeaderMatcher_StringMatch{ StringMatch: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_Exact{ Exact: hdr.Exact, }, IgnoreCase: hdr.IgnoreCase, }, } case hdr.Regex != "": eh.HeaderMatchSpecifier = &envoy_route_v3.HeaderMatcher_StringMatch{ StringMatch: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_SafeRegex{ SafeRegex: response.MakeEnvoyRegexMatch(hdr.Regex), }, // IgnoreCase is not supported for SafeRegex matching per Envoy docs. }, } case hdr.Prefix != "": eh.HeaderMatchSpecifier = &envoy_route_v3.HeaderMatcher_StringMatch{ StringMatch: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_Prefix{ Prefix: hdr.Prefix, }, IgnoreCase: hdr.IgnoreCase, }, } case hdr.Suffix != "": eh.HeaderMatchSpecifier = &envoy_route_v3.HeaderMatcher_StringMatch{ StringMatch: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_Suffix{ Suffix: hdr.Suffix, }, IgnoreCase: hdr.IgnoreCase, }, } case hdr.Contains != "": eh.HeaderMatchSpecifier = &envoy_route_v3.HeaderMatcher_StringMatch{ StringMatch: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_Contains{ Contains: hdr.Contains, }, IgnoreCase: hdr.IgnoreCase, }, } case hdr.Present: eh.HeaderMatchSpecifier = &envoy_route_v3.HeaderMatcher_PresentMatch{ PresentMatch: true, } default: continue // skip this impossible situation } if hdr.Invert { eh.InvertMatch = true } parts = append(parts, &envoy_rbac_v3.Permission{ Rule: &envoy_rbac_v3.Permission_Header{ Header: eh, }, }) } if len(perm.HTTP.Methods) > 0 { methodHeaderRegex := strings.Join(perm.HTTP.Methods, "|") eh := &envoy_route_v3.HeaderMatcher{ Name: ":method", HeaderMatchSpecifier: &envoy_route_v3.HeaderMatcher_StringMatch{ StringMatch: &envoy_matcher_v3.StringMatcher{ MatchPattern: &envoy_matcher_v3.StringMatcher_SafeRegex{ SafeRegex: response.MakeEnvoyRegexMatch(methodHeaderRegex), }, }, }, } parts = append(parts, &envoy_rbac_v3.Permission{ Rule: &envoy_rbac_v3.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 *envoy_rbac_v3.Permission) *envoy_rbac_v3.Permission { return &envoy_rbac_v3.Permission{ Rule: &envoy_rbac_v3.Permission_NotRule{NotRule: perm}, } } func andPermissions(perms []*envoy_rbac_v3.Permission) *envoy_rbac_v3.Permission { switch len(perms) { case 0: return anyPermission() case 1: return perms[0] default: return &envoy_rbac_v3.Permission{ Rule: &envoy_rbac_v3.Permission_AndRules{ AndRules: &envoy_rbac_v3.Permission_Set{ Rules: perms, }, }, } } } func orPermissions(perms []*envoy_rbac_v3.Permission) *envoy_rbac_v3.Permission { switch len(perms) { case 0: return anyPermission() case 1: return perms[0] default: return &envoy_rbac_v3.Permission{ Rule: &envoy_rbac_v3.Permission_OrRules{ OrRules: &envoy_rbac_v3.Permission_Set{ Rules: perms, }, }, } } }