From c513103bee39e1ebc3793e7128941794667779de Mon Sep 17 00:00:00 2001 From: Zach Reyes <39203661+zasweq@users.noreply.github.com> Date: Wed, 21 Jul 2021 22:42:38 -0400 Subject: [PATCH] Add extra layer on top of RBAC Engine (#4576) * Add extra layer in RBAC --- internal/xds/rbac/matchers.go | 70 +- internal/xds/rbac/rbac_engine.go | 217 +++++- internal/xds/rbac/rbac_engine_test.go | 1000 ++++++++++++++++--------- 3 files changed, 885 insertions(+), 402 deletions(-) diff --git a/internal/xds/rbac/matchers.go b/internal/xds/rbac/matchers.go index 47be35c1d0d7..25d1cc0e8b98 100644 --- a/internal/xds/rbac/matchers.go +++ b/internal/xds/rbac/matchers.go @@ -32,7 +32,7 @@ import ( // matcher is an interface that takes data about incoming RPC's and returns // whether it matches with whatever matcher implements this interface. type matcher interface { - match(data *RPCData) bool + match(data *rpcData) bool } // policyMatcher helps determine whether an incoming RPC call matches a policy. @@ -63,7 +63,7 @@ func newPolicyMatcher(policy *v3rbacpb.Policy) (*policyMatcher, error) { }, nil } -func (pm *policyMatcher) match(data *RPCData) bool { +func (pm *policyMatcher) match(data *rpcData) bool { // A policy matches if and only if at least one of its permissions match the // action taking place AND at least one if its principals match the // downstream peer. @@ -202,7 +202,7 @@ type orMatcher struct { matchers []matcher } -func (om *orMatcher) match(data *RPCData) bool { +func (om *orMatcher) match(data *rpcData) bool { // Range through child matchers and pass in data about incoming RPC, and // only one child matcher has to match to be logically successful. for _, m := range om.matchers { @@ -219,7 +219,7 @@ type andMatcher struct { matchers []matcher } -func (am *andMatcher) match(data *RPCData) bool { +func (am *andMatcher) match(data *rpcData) bool { for _, m := range am.matchers { if !m.match(data) { return false @@ -234,7 +234,7 @@ func (am *andMatcher) match(data *RPCData) bool { type alwaysMatcher struct { } -func (am *alwaysMatcher) match(data *RPCData) bool { +func (am *alwaysMatcher) match(data *rpcData) bool { return true } @@ -244,7 +244,7 @@ type notMatcher struct { matcherToNot matcher } -func (nm *notMatcher) match(data *RPCData) bool { +func (nm *notMatcher) match(data *rpcData) bool { return !nm.matcherToNot.match(data) } @@ -284,8 +284,8 @@ func newHeaderMatcher(headerMatcherConfig *v3route_componentspb.HeaderMatcher) ( return &headerMatcher{matcher: m}, nil } -func (hm *headerMatcher) match(data *RPCData) bool { - return hm.matcher.Match(data.MD) +func (hm *headerMatcher) match(data *rpcData) bool { + return hm.matcher.Match(data.md) } // urlPathMatcher matches on the URL Path of the incoming RPC. In gRPC, this @@ -303,8 +303,8 @@ func newURLPathMatcher(pathMatcher *v3matcherpb.PathMatcher) (*urlPathMatcher, e return &urlPathMatcher{stringMatcher: stringMatcher}, nil } -func (upm *urlPathMatcher) match(data *RPCData) bool { - return upm.stringMatcher.Match(data.FullMethod) +func (upm *urlPathMatcher) match(data *rpcData) bool { + return upm.stringMatcher.Match(data.fullMethod) } // sourceIPMatcher and destinationIPMatcher both are matchers that match against @@ -329,8 +329,8 @@ func newSourceIPMatcher(cidrRange *v3corepb.CidrRange) (*sourceIPMatcher, error) return &sourceIPMatcher{ipNet: ipNet}, nil } -func (sim *sourceIPMatcher) match(data *RPCData) bool { - return sim.ipNet.Contains(net.IP(net.ParseIP(data.PeerInfo.Addr.String()))) +func (sim *sourceIPMatcher) match(data *rpcData) bool { + return sim.ipNet.Contains(net.IP(net.ParseIP(data.peerInfo.Addr.String()))) } type destinationIPMatcher struct { @@ -346,8 +346,8 @@ func newDestinationIPMatcher(cidrRange *v3corepb.CidrRange) (*destinationIPMatch return &destinationIPMatcher{ipNet: ipNet}, nil } -func (dim *destinationIPMatcher) match(data *RPCData) bool { - return dim.ipNet.Contains(net.IP(net.ParseIP(data.DestinationAddr.String()))) +func (dim *destinationIPMatcher) match(data *rpcData) bool { + return dim.ipNet.Contains(net.IP(net.ParseIP(data.destinationAddr.String()))) } // portMatcher matches on whether the destination port of the RPC matches the @@ -361,8 +361,8 @@ func newPortMatcher(destinationPort uint32) *portMatcher { return &portMatcher{destinationPort: destinationPort} } -func (pm *portMatcher) match(data *RPCData) bool { - return data.DestinationPort == pm.destinationPort +func (pm *portMatcher) match(data *rpcData) bool { + return data.destinationPort == pm.destinationPort } // authenticatedMatcher matches on the name of the Principal. If set, the URI @@ -370,17 +370,47 @@ func (pm *portMatcher) match(data *RPCData) bool { // subject field is used. If unset, it applies to any user that is // authenticated. authenticatedMatcher implements the matcher interface. type authenticatedMatcher struct { - stringMatcher internalmatcher.StringMatcher + stringMatcher *internalmatcher.StringMatcher } func newAuthenticatedMatcher(authenticatedMatcherConfig *v3rbacpb.Principal_Authenticated) (*authenticatedMatcher, error) { + // Represents this line in the RBAC documentation = "If unset, it applies to + // any user that is authenticated" (see package-level comments). + if authenticatedMatcherConfig.PrincipalName == nil { + return &authenticatedMatcher{}, nil + } stringMatcher, err := internalmatcher.StringMatcherFromProto(authenticatedMatcherConfig.PrincipalName) if err != nil { return nil, err } - return &authenticatedMatcher{stringMatcher: stringMatcher}, nil + return &authenticatedMatcher{stringMatcher: &stringMatcher}, nil } -func (am *authenticatedMatcher) match(data *RPCData) bool { - return am.stringMatcher.Match(data.PrincipalName) +func (am *authenticatedMatcher) match(data *rpcData) bool { + // Represents this line in the RBAC documentation = "If unset, it applies to + // any user that is authenticated" (see package-level comments). An + // authenticated downstream in a stateful TLS connection will have to + // provide a certificate to prove their identity. Thus, you can simply check + // if there is a certificate present. + if am.stringMatcher == nil { + return len(data.certs) != 0 + } + // No certificate present, so will never successfully match. + if len(data.certs) == 0 { + return false + } + cert := data.certs[0] + // The order of matching as per the RBAC documentation (see package-level comments) + // is as follows: URI SANs, DNS SANs, and then subject name. + for _, uriSAN := range cert.URIs { + if am.stringMatcher.Match(uriSAN.String()) { + return true + } + } + for _, dnsSAN := range cert.DNSNames { + if am.stringMatcher.Match(dnsSAN) { + return true + } + } + return am.stringMatcher.Match(cert.Subject.String()) } diff --git a/internal/xds/rbac/rbac_engine.go b/internal/xds/rbac/rbac_engine.go index 29c96d9fcf20..609d123c8039 100644 --- a/internal/xds/rbac/rbac_engine.go +++ b/internal/xds/rbac/rbac_engine.go @@ -15,68 +15,207 @@ */ // Package rbac provides service-level and method-level access control for a -// service. +// service. See +// https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/rbac/v3/rbac.proto#role-based-access-control-rbac +// for documentation. package rbac import ( + "context" + "crypto/x509" + "errors" + "fmt" "net" + "strconv" v3rbacpb "github.com/envoyproxy/go-control-plane/envoy/config/rbac/v3" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" ) -// Engine is used for matching incoming RPCs to policies. -type Engine struct { - policies map[string]*policyMatcher +// ChainEngine represents a chain of RBAC Engines, used to make authorization +// decisions on incoming RPCs. +type ChainEngine struct { + chainedEngines []*engine } -// NewEngine creates an RBAC Engine based on the contents of policy. If the -// config is invalid (and fails to build underlying tree of matchers), NewEngine -// will return an error. This created RBAC Engine will not persist the action -// present in the policy, and will leave up to caller to handle the action that -// is attached to the config. -func NewEngine(policy *v3rbacpb.RBAC) (*Engine, error) { - policies := make(map[string]*policyMatcher) - for name, config := range policy.Policies { - matcher, err := newPolicyMatcher(config) +// NewChainEngine returns a chain of RBAC engines, used to make authorization +// decisions on incoming RPCs. Returns a non-nil error for invalid policies. +func NewChainEngine(policies []*v3rbacpb.RBAC) (*ChainEngine, error) { + var engines []*engine + for _, policy := range policies { + engine, err := newEngine(policy) if err != nil { return nil, err } - policies[name] = matcher + engines = append(engines, engine) } - return &Engine{policies: policies}, nil + return &ChainEngine{chainedEngines: engines}, nil } -// RPCData wraps data pulled from an incoming RPC that the RBAC engine needs to -// find a matching policy. -type RPCData struct { - // MD is the HTTP Headers that are present in the incoming RPC. - MD metadata.MD - // PeerInfo is information about the downstream peer. - PeerInfo *peer.Peer - // FullMethod is the method name being called on the upstream service. - FullMethod string - // DestinationPort is the port that the RPC is being sent to on the - // server. - DestinationPort uint32 - // DestinationAddr is the address that the RPC is being sent to. - DestinationAddr net.Addr - // PrincipalName is the name of the downstream principal. If set, the URI - // SAN or DNS SAN in that order is used from the certificate, otherwise the - // subject field is used. If unset, it applies to any user that is - // authenticated. - PrincipalName string +// IsAuthorized determines if an incoming RPC is authorized based on the chain of RBAC +// engines and their associated actions. +// +// Errors returned by this function are compatible with the status package. +func (cre *ChainEngine) IsAuthorized(ctx context.Context) error { + // This conversion step (i.e. pulling things out of ctx) can be done once, + // and then be used for the whole chain of RBAC Engines. + rpcData, err := newRPCData(ctx) + if err != nil { + return status.Errorf(codes.InvalidArgument, "missing fields in ctx %+v: %v", ctx, err) + } + for _, engine := range cre.chainedEngines { + matchingPolicyName, ok := engine.findMatchingPolicy(rpcData) + + switch { + case engine.action == v3rbacpb.RBAC_ALLOW && !ok: + return status.Errorf(codes.PermissionDenied, "incoming RPC did not match an allow policy") + case engine.action == v3rbacpb.RBAC_DENY && ok: + return status.Errorf(codes.PermissionDenied, "incoming RPC matched a deny policy %q", matchingPolicyName) + } + // Every policy in the engine list must be queried. Thus, iterate to the + // next policy. + } + // If the incoming RPC gets through all of the engines successfully (i.e. + // doesn't not match an allow or match a deny engine), the RPC is authorized + // to proceed. + return status.Error(codes.OK, "") +} + +// engine is used for matching incoming RPCs to policies. +type engine struct { + policies map[string]*policyMatcher + // action must be ALLOW or DENY. + action v3rbacpb.RBAC_Action } -// FindMatchingPolicy determines if an incoming RPC matches a policy. On a -// successful match, it returns the name of the matching policy and a true -// boolean to specify that there was a matching policy found. -func (r *Engine) FindMatchingPolicy(data *RPCData) (string, bool) { +// newEngine creates an RBAC Engine based on the contents of policy. Returns a +// non-nil error if the policy is invalid. +func newEngine(config *v3rbacpb.RBAC) (*engine, error) { + a := *config.Action.Enum() + if a != v3rbacpb.RBAC_ALLOW && a != v3rbacpb.RBAC_DENY { + return nil, fmt.Errorf("unsupported action %s", config.Action) + } + + policies := make(map[string]*policyMatcher, len(config.Policies)) + for name, policy := range config.Policies { + matcher, err := newPolicyMatcher(policy) + if err != nil { + return nil, err + } + policies[name] = matcher + } + return &engine{ + policies: policies, + action: a, + }, nil +} + +// findMatchingPolicy determines if an incoming RPC matches a policy. On a +// successful match, it returns the name of the matching policy and a true bool +// to specify that there was a matching policy found. It returns false in +// the case of not finding a matching policy. +func (r *engine) findMatchingPolicy(rpcData *rpcData) (string, bool) { for policy, matcher := range r.policies { - if matcher.match(data) { + if matcher.match(rpcData) { return policy, true } } return "", false } + +// newRPCData takes an incoming context (should be a context representing state +// needed for server RPC Call with metadata, peer info (used for source ip/port +// and TLS information) and connection (used for destination ip/port) piped into +// it) and the method name of the Service being called server side and populates +// an rpcData struct ready to be passed to the RBAC Engine to find a matching +// policy. +func newRPCData(ctx context.Context) (*rpcData, error) { + // The caller should populate all of these fields (i.e. for empty headers, + // pipe an empty md into context). + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, errors.New("missing metadata in incoming context") + } + + pi, ok := peer.FromContext(ctx) + if !ok { + return nil, errors.New("missing peer info in incoming context") + } + + // The methodName will be available in the passed in ctx from a unary or streaming + // interceptor, as grpc.Server pipes in a transport stream which contains the methodName + // into contexts available in both unary or streaming interceptors. + mn, ok := grpc.Method(ctx) + if !ok { + return nil, errors.New("missing method in incoming context") + } + + // The connection is needed in order to find the destination address and + // port of the incoming RPC Call. + conn := getConnection(ctx) + if conn == nil { + return nil, errors.New("missing connection in incoming context") + } + _, dPort, err := net.SplitHostPort(conn.LocalAddr().String()) + if err != nil { + return nil, fmt.Errorf("error parsing local address: %v", err) + } + dp, err := strconv.ParseUint(dPort, 10, 32) + if err != nil { + return nil, fmt.Errorf("error parsing local address: %v", err) + } + + var peerCertificates []*x509.Certificate + if pi.AuthInfo != nil { + tlsInfo, ok := pi.AuthInfo.(credentials.TLSInfo) + if ok { + peerCertificates = tlsInfo.State.PeerCertificates + } + } + + return &rpcData{ + md: md, + peerInfo: pi, + fullMethod: mn, + destinationPort: uint32(dp), + destinationAddr: conn.LocalAddr(), + certs: peerCertificates, + }, nil +} + +// rpcData wraps data pulled from an incoming RPC that the RBAC engine needs to +// find a matching policy. +type rpcData struct { + // md is the HTTP Headers that are present in the incoming RPC. + md metadata.MD + // peerInfo is information about the downstream peer. + peerInfo *peer.Peer + // fullMethod is the method name being called on the upstream service. + fullMethod string + // destinationPort is the port that the RPC is being sent to on the + // server. + destinationPort uint32 + // destinationAddr is the address that the RPC is being sent to. + destinationAddr net.Addr + // certs are the certificates presented by the peer during a TLS + // handshake. + certs []*x509.Certificate +} + +type connectionKey struct{} + +func getConnection(ctx context.Context) net.Conn { + conn, _ := ctx.Value(connectionKey{}).(net.Conn) + return conn +} + +// SetConnection adds the connection to the context to be able to get +// information about the destination ip and port for an incoming RPC. +func SetConnection(ctx context.Context, conn net.Conn) context.Context { + return context.WithValue(ctx, connectionKey{}, conn) +} diff --git a/internal/xds/rbac/rbac_engine_test.go b/internal/xds/rbac/rbac_engine_test.go index 47ed5a1342e0..2521ac4526aa 100644 --- a/internal/xds/rbac/rbac_engine_test.go +++ b/internal/xds/rbac/rbac_engine_test.go @@ -17,6 +17,12 @@ package rbac import ( + "context" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "net" + "net/url" "testing" v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" @@ -25,8 +31,13 @@ import ( v3matcherpb "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" v3typepb "github.com/envoyproxy/go-control-plane/envoy/type/v3" wrapperspb "github.com/golang/protobuf/ptypes/wrappers" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/internal/grpctest" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" ) type s struct { @@ -44,110 +55,196 @@ type addr struct { func (addr) Network() string { return "" } func (a *addr) String() string { return a.ipAddress } -// TestRBACEngineConstruction tests the construction of the RBAC Engine. Due to -// some types of RBAC configuration being logically wrong and returning an error +// TestNewChainEngine tests the construction of the ChainEngine. Due to some +// types of RBAC configuration being logically wrong and returning an error // rather than successfully constructing the RBAC Engine, this test tests both // RBAC Configurations deemed successful and also RBAC Configurations that will // raise errors. -func (s) TestRBACEngineConstruction(t *testing.T) { +func (s) TestNewChainEngine(t *testing.T) { tests := []struct { - name string - rbacConfig *v3rbacpb.RBAC - wantErr bool + name string + policies []*v3rbacpb.RBAC + wantErr bool }{ { - name: "TestSuccessCaseAnyMatch", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "anyone": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_Any{Any: true}}, - }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + name: "SuccessCaseAnyMatchSingular", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_ALLOW, + Policies: map[string]*v3rbacpb.Policy{ + "anyone": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, }, }, }, }, { - name: "TestSuccessCaseSimplePolicy", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "localhost-fan": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_DestinationPort{DestinationPort: 8080}}, - {Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "localhost-fan-page"}}}}}}, + name: "SuccessCaseAnyMatchMultiple", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_ALLOW, + Policies: map[string]*v3rbacpb.Policy{ + "anyone": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, + }, + { + Action: v3rbacpb.RBAC_DENY, + Policies: map[string]*v3rbacpb.Policy{ + "anyone": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, }, }, }, }, { - name: "TestSuccessCaseEnvoyExample", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "service-admin": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_Any{Any: true}}, - }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Authenticated_{Authenticated: &v3rbacpb.Principal_Authenticated{PrincipalName: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "cluster.local/ns/default/sa/admin"}}}}}, - {Identifier: &v3rbacpb.Principal_Authenticated_{Authenticated: &v3rbacpb.Principal_Authenticated{PrincipalName: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "cluster.local/ns/default/sa/superuser"}}}}}, - }, - }, - "product-viewer": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_AndRules{AndRules: &v3rbacpb.Permission_Set{ - Rules: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "GET"}}}}, - {Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Prefix{Prefix: "/products"}}}}}}, - {Rule: &v3rbacpb.Permission_OrRules{OrRules: &v3rbacpb.Permission_Set{ - Rules: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_DestinationPort{DestinationPort: 80}}, - {Rule: &v3rbacpb.Permission_DestinationPort{DestinationPort: 443}}, - }, - }}}, - }, + name: "SuccessCaseSimplePolicySingular", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_ALLOW, + Policies: map[string]*v3rbacpb.Policy{ + "localhost-fan": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_DestinationPort{DestinationPort: 8080}}, + {Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "localhost-fan-page"}}}}}}, }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, + }, + }, + }, + }, + }, + // SuccessCaseSimplePolicyTwoPolicies tests the construction of the + // chained engines in the case where there are two policies in a list, + // one with an allow policy and one with a deny policy. A situation + // where two policies (allow and deny) is a very common use case for + // this API, and should successfully build. + { + name: "SuccessCaseSimplePolicyTwoPolicies", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_ALLOW, + Policies: map[string]*v3rbacpb.Policy{ + "localhost-fan": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_DestinationPort{DestinationPort: 8080}}, + {Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "localhost-fan-page"}}}}}}, }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, }, }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, + }, + { + Action: v3rbacpb.RBAC_DENY, + Policies: map[string]*v3rbacpb.Policy{ + "localhost-fan": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_DestinationPort{DestinationPort: 8080}}, + {Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "localhost-fan-page"}}}}}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, }, }, }, }, { - name: "TestSourceIpMatcherSuccess", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "certain-source-ip": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_Any{Any: true}}, + name: "SuccessCaseEnvoyExampleSingular", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_ALLOW, + Policies: map[string]*v3rbacpb.Policy{ + "service-admin": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Authenticated_{Authenticated: &v3rbacpb.Principal_Authenticated{PrincipalName: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "cluster.local/ns/default/sa/admin"}}}}}, + {Identifier: &v3rbacpb.Principal_Authenticated_{Authenticated: &v3rbacpb.Principal_Authenticated{PrincipalName: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "cluster.local/ns/default/sa/superuser"}}}}}, + }, }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_DirectRemoteIp{DirectRemoteIp: &v3corepb.CidrRange{AddressPrefix: "0.0.0.0", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, + "product-viewer": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_AndRules{AndRules: &v3rbacpb.Permission_Set{ + Rules: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "GET"}}}}, + {Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Prefix{Prefix: "/products"}}}}}}, + {Rule: &v3rbacpb.Permission_OrRules{OrRules: &v3rbacpb.Permission_Set{ + Rules: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_DestinationPort{DestinationPort: 80}}, + {Rule: &v3rbacpb.Permission_DestinationPort{DestinationPort: 443}}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, }, }, }, }, { - name: "TestSourceIpMatcherFailure", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "certain-source-ip": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_Any{Any: true}}, + name: "SourceIpMatcherSuccessSingular", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_ALLOW, + Policies: map[string]*v3rbacpb.Policy{ + "certain-source-ip": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_DirectRemoteIp{DirectRemoteIp: &v3corepb.CidrRange{AddressPrefix: "0.0.0.0", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, + }, }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_DirectRemoteIp{DirectRemoteIp: &v3corepb.CidrRange{AddressPrefix: "not a correct address", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, + }, + }, + }, + }, + { + name: "SourceIpMatcherFailureSingular", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_ALLOW, + Policies: map[string]*v3rbacpb.Policy{ + "certain-source-ip": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_DirectRemoteIp{DirectRemoteIp: &v3corepb.CidrRange{AddressPrefix: "not a correct address", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, + }, }, }, }, @@ -155,30 +252,36 @@ func (s) TestRBACEngineConstruction(t *testing.T) { wantErr: true, }, { - name: "TestDestinationIpMatcherSuccess", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "certain-destination-ip": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_DestinationIp{DestinationIp: &v3corepb.CidrRange{AddressPrefix: "0.0.0.0", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, - }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + name: "DestinationIpMatcherSuccess", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_ALLOW, + Policies: map[string]*v3rbacpb.Policy{ + "certain-destination-ip": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_DestinationIp{DestinationIp: &v3corepb.CidrRange{AddressPrefix: "0.0.0.0", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, }, }, }, }, { - name: "TestDestinationIpMatcherFailure", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "certain-destination-ip": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_DestinationIp{DestinationIp: &v3corepb.CidrRange{AddressPrefix: "not a correct address", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, - }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + name: "DestinationIpMatcherFailure", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_ALLOW, + Policies: map[string]*v3rbacpb.Policy{ + "certain-destination-ip": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_DestinationIp{DestinationIp: &v3corepb.CidrRange{AddressPrefix: "not a correct address", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, }, }, @@ -186,82 +289,135 @@ func (s) TestRBACEngineConstruction(t *testing.T) { wantErr: true, }, { - name: "TestMatcherToNotPolicy", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "not-secret-content": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_NotRule{NotRule: &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Prefix{Prefix: "/secret-content"}}}}}}}}, - }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + name: "MatcherToNotPolicy", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_ALLOW, + Policies: map[string]*v3rbacpb.Policy{ + "not-secret-content": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_NotRule{NotRule: &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Prefix{Prefix: "/secret-content"}}}}}}}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, }, }, }, }, { - name: "TestMatcherToNotPrincipal", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "not-from-certain-ip": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_Any{Any: true}}, + name: "MatcherToNotPrinicipal", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_ALLOW, + Policies: map[string]*v3rbacpb.Policy{ + "not-from-certain-ip": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_NotId{NotId: &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_DirectRemoteIp{DirectRemoteIp: &v3corepb.CidrRange{AddressPrefix: "0.0.0.0", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}}}, + }, }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_NotId{NotId: &v3rbacpb.Principal{Identifier: &v3rbacpb.Principal_DirectRemoteIp{DirectRemoteIp: &v3corepb.CidrRange{AddressPrefix: "0.0.0.0", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}}}, + }, + }, + }, + }, + // PrinicpalProductViewer tests the construction of a chained engine + // with a policy that allows any downstream to send a GET request on a + // certain path. + { + name: "PrincipalProductViewer", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_ALLOW, + Policies: map[string]*v3rbacpb.Policy{ + "product-viewer": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + { + Identifier: &v3rbacpb.Principal_AndIds{AndIds: &v3rbacpb.Principal_Set{Ids: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "GET"}}}}, + {Identifier: &v3rbacpb.Principal_OrIds{OrIds: &v3rbacpb.Principal_Set{ + Ids: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Prefix{Prefix: "/books"}}}}}}, + {Identifier: &v3rbacpb.Principal_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Prefix{Prefix: "/cars"}}}}}}, + }, + }}}, + }}}, + }, + }, }, }, }, }, }, + // Certain Headers tests the construction of a chained engine with a + // policy that allows any downstream to send an HTTP request with + // certain headers. { - name: "TestPrincipalProductViewer", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "product-viewer": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_Any{Any: true}}, - }, - Principals: []*v3rbacpb.Principal{ - { - Identifier: &v3rbacpb.Principal_AndIds{AndIds: &v3rbacpb.Principal_Set{Ids: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "GET"}}}}, - {Identifier: &v3rbacpb.Principal_OrIds{OrIds: &v3rbacpb.Principal_Set{ - Ids: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Prefix{Prefix: "/books"}}}}}}, - {Identifier: &v3rbacpb.Principal_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Prefix{Prefix: "/cars"}}}}}}, - }, + name: "CertainHeaders", + policies: []*v3rbacpb.RBAC{ + { + Policies: map[string]*v3rbacpb.Policy{ + "certain-headers": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + { + Identifier: &v3rbacpb.Principal_OrIds{OrIds: &v3rbacpb.Principal_Set{Ids: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "GET"}}}}, + {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_SafeRegexMatch{SafeRegexMatch: &v3matcherpb.RegexMatcher{Regex: "GET"}}}}}, + {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_RangeMatch{RangeMatch: &v3typepb.Int64Range{ + Start: 0, + End: 64, + }}}}}, + {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_PresentMatch{PresentMatch: true}}}}, + {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_PrefixMatch{PrefixMatch: "GET"}}}}, + {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_SuffixMatch{SuffixMatch: "GET"}}}}, + {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ContainsMatch{ContainsMatch: "GET"}}}}, }}}, - }}}, + }, + }, + }, + }, + }, + }, + }, + { + name: "LogAction", + policies: []*v3rbacpb.RBAC{ + { + Action: v3rbacpb.RBAC_LOG, + Policies: map[string]*v3rbacpb.Policy{ + "anyone": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, }, }, }, }, }, + wantErr: true, }, { - name: "TestCertainHeaders", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "certain-headers": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_Any{Any: true}}, - }, - Principals: []*v3rbacpb.Principal{ - { - Identifier: &v3rbacpb.Principal_OrIds{OrIds: &v3rbacpb.Principal_Set{Ids: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "GET"}}}}, - {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_SafeRegexMatch{SafeRegexMatch: &v3matcherpb.RegexMatcher{Regex: "GET"}}}}}, - {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_RangeMatch{RangeMatch: &v3typepb.Int64Range{ - Start: 0, - End: 64, - }}}}}, - {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_PresentMatch{PresentMatch: true}}}}, - {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_PrefixMatch{PrefixMatch: "GET"}}}}, - {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_SuffixMatch{SuffixMatch: "GET"}}}}, - {Identifier: &v3rbacpb.Principal_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ContainsMatch{ContainsMatch: "GET"}}}}, - }}}, + name: "ActionNotSpecified", + policies: []*v3rbacpb.RBAC{ + { + Policies: map[string]*v3rbacpb.Policy{ + "anyone": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, }, }, }, @@ -271,336 +427,494 @@ func (s) TestRBACEngineConstruction(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - if _, err := NewEngine(test.rbacConfig); (err != nil) != test.wantErr { - t.Fatalf("NewEngine(%+v) returned err: %v, wantErr: %v", test.rbacConfig, err, test.wantErr) + if _, err := NewChainEngine(test.policies); (err != nil) != test.wantErr { + t.Fatalf("NewChainEngine(%+v) returned err: %v, wantErr: %v", test.policies, err, test.wantErr) } }) } } -// TestRBACEngine tests the RBAC Engine by configuring the engine in different -// scenarios. After configuring the engine in a certain way, this test pings the -// engine with different kinds of data representing incoming RPC's, and verifies -// that it works as expected. -func (s) TestRBACEngine(t *testing.T) { +// TestChainEngine tests the chain of RBAC Engines by configuring the chain of +// engines in a certain way in different scenarios. After configuring the chain +// of engines in a certain way, this test pings the chain of engines with +// different types of data representing incoming RPC's (piped into a context), +// and verifies that it works as expected. +func (s) TestChainEngine(t *testing.T) { tests := []struct { name string - rbacConfig *v3rbacpb.RBAC + rbacConfigs []*v3rbacpb.RBAC rbacQueries []struct { - rpcData *RPCData - wantMatchingPolicyName string - wantMatch bool + rpcData *rpcData + wantStatusCode codes.Code } }{ - // TestSuccessCaseAnyMatch tests an RBAC Engine instantiated with a - // config with a policy with any rules for both permissions and + // SuccessCaseAnyMatch tests a single RBAC Engine instantiated with + // a config with a policy with any rules for both permissions and // principals, meaning that any data about incoming RPC's that the RBAC // Engine is queried with should match that policy. { - name: "TestSuccessCaseAnyMatch", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "anyone": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_Any{Any: true}}, - }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + name: "SuccessCaseAnyMatch", + rbacConfigs: []*v3rbacpb.RBAC{ + { + Policies: map[string]*v3rbacpb.Policy{ + "anyone": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, }, }, }, - rbacQueries: // Any incoming RPC should match with the anyone policy - []struct { - rpcData *RPCData - wantMatchingPolicyName string - wantMatch bool + rbacQueries: []struct { + rpcData *rpcData + wantStatusCode codes.Code }{ { - rpcData: &RPCData{ - FullMethod: "some method", - }, - wantMatchingPolicyName: "anyone", - wantMatch: true, - }, - { - rpcData: &RPCData{ - DestinationPort: 100, + rpcData: &rpcData{ + fullMethod: "some method", + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "0.0.0.0"}, + }, }, - wantMatchingPolicyName: "anyone", - wantMatch: true, + wantStatusCode: codes.OK, }, }, }, - // TestSuccessCaseSimplePolicy is a test that tests a simple policy that - // only allows an rpc to proceed if the rpc is calling a certain path - // and port. + // SuccessCaseSimplePolicy is a test that tests a single policy + // that only allows an rpc to proceed if the rpc is calling with a certain + // path. { - name: "TestSuccessCaseSimplePolicy", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "localhost-fan": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_DestinationPort{DestinationPort: 8080}}, - {Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "localhost-fan-page"}}}}}}, - }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + name: "SuccessCaseSimplePolicy", + rbacConfigs: []*v3rbacpb.RBAC{ + { + Policies: map[string]*v3rbacpb.Policy{ + "localhost-fan": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "localhost-fan-page"}}}}}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, }, }, }, rbacQueries: []struct { - rpcData *RPCData - wantMatchingPolicyName string - wantMatch bool + rpcData *rpcData + wantStatusCode codes.Code }{ - // This RPC should match with the local host fan policy. + // This RPC should match with the local host fan policy. Thus, + // this RPC should be allowed to proceed. { - rpcData: &RPCData{ - MD: map[string][]string{ - ":path": {"localhost-fan-page"}, + rpcData: &rpcData{ + fullMethod: "localhost-fan-page", + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "0.0.0.0"}, }, - DestinationPort: 8080, }, - wantMatchingPolicyName: "localhost-fan", - wantMatch: true}, - // This RPC shouldn't match with the local host fan policy. + wantStatusCode: codes.OK, + }, + + // This RPC shouldn't match with the local host fan policy. Thus, + // this rpc shouldn't be allowed to proceed. { - rpcData: &RPCData{ - DestinationPort: 100, + rpcData: &rpcData{ + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "0.0.0.0"}, + }, }, - wantMatchingPolicyName: ""}, + wantStatusCode: codes.PermissionDenied, + }, }, }, - // TestSuccessCaseEnvoyExample is a test based on the example provided + // SuccessCaseEnvoyExample is a test based on the example provided // in the EnvoyProxy docs. The RBAC Config contains two policies, // service admin and product viewer, that provides an example of a real // RBAC Config that might be configured for a given for a given backend // service. { - name: "TestSuccessCaseEnvoyExample", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "service-admin": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_Any{Any: true}}, - }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Authenticated_{Authenticated: &v3rbacpb.Principal_Authenticated{PrincipalName: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "cluster.local/ns/default/sa/admin"}}}}}, - {Identifier: &v3rbacpb.Principal_Authenticated_{Authenticated: &v3rbacpb.Principal_Authenticated{PrincipalName: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "cluster.local/ns/default/sa/superuser"}}}}}, - }, - }, - "product-viewer": { - Permissions: []*v3rbacpb.Permission{ - { - Rule: &v3rbacpb.Permission_AndRules{AndRules: &v3rbacpb.Permission_Set{ - Rules: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "GET"}}}}, - {Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Prefix{Prefix: "/products"}}}}}}, - {Rule: &v3rbacpb.Permission_OrRules{OrRules: &v3rbacpb.Permission_Set{ - Rules: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_DestinationPort{DestinationPort: 80}}, - {Rule: &v3rbacpb.Permission_DestinationPort{DestinationPort: 443}}, - }, - }}}, + name: "SuccessCaseEnvoyExample", + rbacConfigs: []*v3rbacpb.RBAC{ + { + Policies: map[string]*v3rbacpb.Policy{ + "service-admin": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Authenticated_{Authenticated: &v3rbacpb.Principal_Authenticated{PrincipalName: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "//cluster.local/ns/default/sa/admin"}}}}}, + {Identifier: &v3rbacpb.Principal_Authenticated_{Authenticated: &v3rbacpb.Principal_Authenticated{PrincipalName: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "//cluster.local/ns/default/sa/superuser"}}}}}, + }, + }, + "product-viewer": { + Permissions: []*v3rbacpb.Permission{ + { + Rule: &v3rbacpb.Permission_AndRules{AndRules: &v3rbacpb.Permission_Set{ + Rules: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "GET"}}}}, + {Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Prefix{Prefix: "/products"}}}}}}, + }, + }, }, - }, }, }, - }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, }, }, }, rbacQueries: []struct { - rpcData *RPCData - wantMatchingPolicyName string - wantMatch bool + rpcData *rpcData + wantStatusCode codes.Code }{ // This incoming RPC Call should match with the service admin // policy. { - rpcData: &RPCData{ - FullMethod: "some method", - PrincipalName: "cluster.local/ns/default/sa/admin", - }, - wantMatchingPolicyName: "service-admin", - wantMatch: true}, - // This incoming RPC Call should match with the product - // viewer policy. - { - rpcData: &RPCData{ - DestinationPort: 80, - MD: map[string][]string{ - ":method": {"GET"}, + rpcData: &rpcData{ + fullMethod: "some method", + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "0.0.0.0"}, + AuthInfo: credentials.TLSInfo{ + State: tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{ + { + URIs: []*url.URL{ + { + Host: "cluster.local", + Path: "/ns/default/sa/admin", + }, + }, + }, + }, + }, + }, }, - FullMethod: "/products", }, - wantMatchingPolicyName: "product-viewer", - wantMatch: true}, - // These incoming RPC calls should not match any policy - - // represented by an empty matching policy name and false being - // returned. + wantStatusCode: codes.OK, + }, + // These incoming RPC calls should not match any policy. { - rpcData: &RPCData{ - DestinationPort: 100, + rpcData: &rpcData{ + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "0.0.0.0"}, + }, }, - wantMatchingPolicyName: ""}, + wantStatusCode: codes.PermissionDenied, + }, { - rpcData: &RPCData{ - FullMethod: "get-product-list", - DestinationPort: 8080, + rpcData: &rpcData{ + fullMethod: "get-product-list", + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "0.0.0.0"}, + }, }, - wantMatchingPolicyName: ""}, + wantStatusCode: codes.PermissionDenied, + }, { - rpcData: &RPCData{ - PrincipalName: "localhost", - DestinationPort: 8080, + rpcData: &rpcData{ + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "0.0.0.0"}, + AuthInfo: credentials.TLSInfo{ + State: tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{ + { + Subject: pkix.Name{ + CommonName: "localhost", + }, + }, + }, + }, + }, + }, }, - wantMatchingPolicyName: ""}, + wantStatusCode: codes.PermissionDenied, + }, }, }, { - name: "TestNotMatcher", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "not-secret-content": { - Permissions: []*v3rbacpb.Permission{ - { - Rule: &v3rbacpb.Permission_NotRule{ - NotRule: &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Prefix{Prefix: "/secret-content"}}}}}}, + name: "NotMatcher", + rbacConfigs: []*v3rbacpb.RBAC{ + { + Policies: map[string]*v3rbacpb.Policy{ + "not-secret-content": { + Permissions: []*v3rbacpb.Permission{ + { + Rule: &v3rbacpb.Permission_NotRule{ + NotRule: &v3rbacpb.Permission{Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Prefix{Prefix: "/secret-content"}}}}}}, + }, }, }, - }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, }, }, }, rbacQueries: []struct { - rpcData *RPCData - wantMatchingPolicyName string - wantMatch bool + rpcData *rpcData + wantStatusCode codes.Code }{ // This incoming RPC Call should match with the not-secret-content policy. { - rpcData: &RPCData{ - FullMethod: "/regular-content", + rpcData: &rpcData{ + fullMethod: "/regular-content", + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "0.0.0.0"}, + }, }, - wantMatchingPolicyName: "not-secret-content", - wantMatch: true, + wantStatusCode: codes.OK, }, - // This incoming RPC Call shouldn't match with the not-secret-content policy. + // This incoming RPC Call shouldn't match with the not-secret-content-policy. { - rpcData: &RPCData{ - FullMethod: "/secret-content", + rpcData: &rpcData{ + fullMethod: "/secret-content", + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "0.0.0.0"}, + }, }, - wantMatchingPolicyName: "", - wantMatch: false, + wantStatusCode: codes.PermissionDenied, }, }, }, { - name: "TestSourceIpMatcher", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "certain-source-ip": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_Any{Any: true}}, - }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_DirectRemoteIp{DirectRemoteIp: &v3corepb.CidrRange{AddressPrefix: "0.0.0.0", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, + name: "SourceIpMatcher", + rbacConfigs: []*v3rbacpb.RBAC{ + { + Policies: map[string]*v3rbacpb.Policy{ + "certain-source-ip": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_DirectRemoteIp{DirectRemoteIp: &v3corepb.CidrRange{AddressPrefix: "0.0.0.0", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, + }, }, }, }, }, rbacQueries: []struct { - rpcData *RPCData - wantMatchingPolicyName string - wantMatch bool + rpcData *rpcData + wantStatusCode codes.Code }{ // This incoming RPC Call should match with the certain-source-ip policy. { - rpcData: &RPCData{ - PeerInfo: &peer.Peer{ + rpcData: &rpcData{ + peerInfo: &peer.Peer{ Addr: &addr{ipAddress: "0.0.0.0"}, }, }, - wantMatchingPolicyName: "certain-source-ip", - wantMatch: true, + wantStatusCode: codes.OK, }, // This incoming RPC Call shouldn't match with the certain-source-ip policy. { - rpcData: &RPCData{ - PeerInfo: &peer.Peer{ + rpcData: &rpcData{ + peerInfo: &peer.Peer{ Addr: &addr{ipAddress: "10.0.0.0"}, }, }, - wantMatchingPolicyName: "", - wantMatch: false, + wantStatusCode: codes.PermissionDenied, }, }, }, { - name: "TestDestinationIpMatcher", - rbacConfig: &v3rbacpb.RBAC{ - Policies: map[string]*v3rbacpb.Policy{ - "certain-destination-ip": { - Permissions: []*v3rbacpb.Permission{ - {Rule: &v3rbacpb.Permission_DestinationIp{DestinationIp: &v3corepb.CidrRange{AddressPrefix: "0.0.0.0", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, + name: "DestinationIpMatcher", + rbacConfigs: []*v3rbacpb.RBAC{ + { + Policies: map[string]*v3rbacpb.Policy{ + "certain-destination-ip": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_DestinationIp{DestinationIp: &v3corepb.CidrRange{AddressPrefix: "0.0.0.0", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, }, - Principals: []*v3rbacpb.Principal{ - {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, + }, + }, + rbacQueries: []struct { + rpcData *rpcData + wantStatusCode codes.Code + }{ + // This incoming RPC Call shouldn't match with the + // certain-destination-ip policy, as the test listens on local + // host. + { + rpcData: &rpcData{ + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "10.0.0.0"}, }, }, + wantStatusCode: codes.PermissionDenied, + }, + }, + }, + // AllowAndDenyPolicy tests a policy with an allow (on path) and + // deny (on port) policy chained together. This represents how a user + // configured interceptor would use this, and also is a potential + // configuration for a dynamic xds interceptor. + { + name: "AllowAndDenyPolicy", + rbacConfigs: []*v3rbacpb.RBAC{ + { + Policies: map[string]*v3rbacpb.Policy{ + "certain-source-ip": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_Any{Any: true}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_DirectRemoteIp{DirectRemoteIp: &v3corepb.CidrRange{AddressPrefix: "0.0.0.0", PrefixLen: &wrapperspb.UInt32Value{Value: uint32(10)}}}}, + }, + }, + }, + Action: v3rbacpb.RBAC_ALLOW, + }, + { + Policies: map[string]*v3rbacpb.Policy{ + "localhost-fan": { + Permissions: []*v3rbacpb.Permission{ + {Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "localhost-fan-page"}}}}}}, + }, + Principals: []*v3rbacpb.Principal{ + {Identifier: &v3rbacpb.Principal_Any{Any: true}}, + }, + }, + }, + Action: v3rbacpb.RBAC_DENY, }, }, rbacQueries: []struct { - rpcData *RPCData - wantMatchingPolicyName string - wantMatch bool + rpcData *rpcData + wantStatusCode codes.Code }{ - // This incoming RPC Call should match with the certain-destination-ip policy. + // This RPC should match with the allow policy, and shouldn't + // match with the deny and thus should be allowed to proceed. + { + rpcData: &rpcData{ + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "0.0.0.0"}, + }, + }, + wantStatusCode: codes.OK, + }, + // This RPC should match with both the allow policy and deny policy + // and thus shouldn't be allowed to proceed as matched with deny. + { + rpcData: &rpcData{ + fullMethod: "localhost-fan-page", + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "0.0.0.0"}, + }, + }, + wantStatusCode: codes.PermissionDenied, + }, + // This RPC shouldn't match with either policy, and thus + // shouldn't be allowed to proceed as didn't match with allow. { - rpcData: &RPCData{ - DestinationAddr: &addr{ipAddress: "0.0.0.10"}, + rpcData: &rpcData{ + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "10.0.0.0"}, + }, }, - wantMatchingPolicyName: "certain-destination-ip", - wantMatch: true, + wantStatusCode: codes.PermissionDenied, }, - // This incoming RPC Call shouldn't match with the certain-destination-ip policy. + // This RPC shouldn't match with allow, match with deny, and + // thus shouldn't be allowed to proceed. { - rpcData: &RPCData{ - DestinationAddr: &addr{ipAddress: "10.0.0.0"}, + rpcData: &rpcData{ + fullMethod: "localhost-fan-page", + peerInfo: &peer.Peer{ + Addr: &addr{ipAddress: "10.0.0.0"}, + }, }, - wantMatchingPolicyName: "", + wantStatusCode: codes.PermissionDenied, }, }, }, } + for _, test := range tests { t.Run(test.name, func(t *testing.T) { - // Instantiate the rbacEngine with different configurations that + // Instantiate the chainedRBACEngine with different configurations that are // interesting to test and to query. - rbacEngine, err := NewEngine(test.rbacConfig) + cre, err := NewChainEngine(test.rbacConfigs) if err != nil { t.Fatalf("Error constructing RBAC Engine: %v", err) } - // Query that created RBAC Engine with different args to see if the - // RBAC Engine configured a certain way works as intended. - for _, queryToRBACEngine := range test.rbacQueries { - // The matchingPolicyName returned will be empty in the case of - // no matching policy. Thus, matchingPolicyName can also be used - // to test the "error" condition of no matching policies. - matchingPolicyName, matchingPolicyFound := rbacEngine.FindMatchingPolicy(queryToRBACEngine.rpcData) - if matchingPolicyFound != queryToRBACEngine.wantMatch || matchingPolicyName != queryToRBACEngine.wantMatchingPolicyName { - t.Errorf("FindMatchingPolicy(%+v) returned (%v, %v), want (%v, %v)", queryToRBACEngine.rpcData, matchingPolicyName, matchingPolicyFound, queryToRBACEngine.wantMatchingPolicyName, queryToRBACEngine.wantMatch) - } + // Query the created chain of RBAC Engines with different args to see + // if the chain of RBAC Engines configured as such works as intended. + for _, data := range test.rbacQueries { + func() { + // Construct the context with three data points that have enough + // information to represent incoming RPC's. This will be how a + // user uses this API. A user will have to put MD, PeerInfo, and + // the connection the RPC is sent on in the context. + ctx := metadata.NewIncomingContext(context.Background(), data.rpcData.md) + + // Make a TCP connection with a certain destination port. The + // address/port of this connection will be used to populate the + // destination ip/port in RPCData struct. This represents what + // the user of ChainEngine will have to place into + // context, as this is only way to get destination ip and port. + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatalf("Error listening: %v", err) + } + defer lis.Close() + connCh := make(chan net.Conn, 1) + go func() { + conn, err := lis.Accept() + if err != nil { + t.Errorf("Error accepting connection: %v", err) + return + } + connCh <- conn + }() + _, err = net.Dial("tcp", lis.Addr().String()) + if err != nil { + t.Fatalf("Error dialing: %v", err) + } + conn := <-connCh + defer conn.Close() + ctx = SetConnection(ctx, conn) + ctx = peer.NewContext(ctx, data.rpcData.peerInfo) + stream := &ServerTransportStreamWithMethod{ + method: data.rpcData.fullMethod, + } + + ctx = grpc.NewContextWithServerTransportStream(ctx, stream) + err = cre.IsAuthorized(ctx) + if gotCode := status.Code(err); gotCode != data.wantStatusCode { + t.Fatalf("IsAuthorized(%+v, %+v) returned (%+v), want(%+v)", ctx, data.rpcData.fullMethod, gotCode, data.wantStatusCode) + } + }() } }) } } + +type ServerTransportStreamWithMethod struct { + method string +} + +func (sts *ServerTransportStreamWithMethod) Method() string { + return sts.method +} + +func (sts *ServerTransportStreamWithMethod) SetHeader(md metadata.MD) error { + return nil +} + +func (sts *ServerTransportStreamWithMethod) SendHeader(md metadata.MD) error { + return nil +} + +func (sts *ServerTransportStreamWithMethod) SetTrailer(md metadata.MD) error { + return nil +}