| /* |
| * |
| * Copyright 2022 gRPC authors. |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| * |
| */ |
| |
| package xds_test |
| |
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "net" |
| "strconv" |
| "strings" |
| "testing" |
| |
| v3xdsxdstypepb "github.com/cncf/xds/go/xds/type/v3" |
| v3routerpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/router/v3" |
| "github.com/google/go-cmp/cmp" |
| "google.golang.org/grpc" |
| "google.golang.org/grpc/authz/audit" |
| "google.golang.org/grpc/codes" |
| "google.golang.org/grpc/connectivity" |
| "google.golang.org/grpc/credentials/insecure" |
| "google.golang.org/grpc/internal/testutils" |
| "google.golang.org/grpc/internal/testutils/xds/e2e" |
| "google.golang.org/grpc/internal/testutils/xds/e2e/setup" |
| "google.golang.org/grpc/status" |
| "google.golang.org/grpc/xds" |
| "google.golang.org/protobuf/types/known/anypb" |
| "google.golang.org/protobuf/types/known/structpb" |
| "google.golang.org/protobuf/types/known/wrapperspb" |
| |
| v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" |
| v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" |
| v3rbacpb "github.com/envoyproxy/go-control-plane/envoy/config/rbac/v3" |
| v3routepb "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" |
| rpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/rbac/v3" |
| v3httppb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" |
| v3matcherpb "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" |
| testgrpc "google.golang.org/grpc/interop/grpc_testing" |
| testpb "google.golang.org/grpc/interop/grpc_testing" |
| ) |
| |
| // TestServerSideXDS_RouteConfiguration is an e2e test which verifies routing |
| // functionality. The xDS enabled server will be set up with route configuration |
| // where the route configuration has routes with the correct routing actions |
| // (NonForwardingAction), and the RPC's matching those routes should proceed as |
| // normal. |
| func (s) TestServerSideXDS_RouteConfiguration(t *testing.T) { |
| managementServer, nodeID, bootstrapContents, xdsResolver := setup.ManagementServerAndResolver(t) |
| |
| lis, cleanup2 := setupGRPCServer(t, bootstrapContents) |
| defer cleanup2() |
| |
| host, port, err := hostPortFromListener(lis) |
| if err != nil { |
| t.Fatalf("failed to retrieve host and port of server: %v", err) |
| } |
| const serviceName = "my-service-fallback" |
| resources := e2e.DefaultClientResources(e2e.ResourceParams{ |
| DialTarget: serviceName, |
| NodeID: nodeID, |
| Host: host, |
| Port: port, |
| SecLevel: e2e.SecurityLevelNone, |
| }) |
| |
| // Create an inbound xDS listener resource with route configuration which |
| // selectively will allow RPC's through or not. This will test routing in |
| // xds(Unary|Stream)Interceptors. |
| vhs := []*v3routepb.VirtualHost{ |
| // Virtual host that will never be matched to test Virtual Host selection. |
| { |
| Domains: []string{"this will not match*"}, |
| Routes: []*v3routepb.Route{ |
| { |
| Match: &v3routepb.RouteMatch{ |
| PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: "/"}, |
| }, |
| Action: &v3routepb.Route_NonForwardingAction{}, |
| }, |
| }, |
| }, |
| // This Virtual Host will actually get matched to. |
| { |
| Domains: []string{"*"}, |
| Routes: []*v3routepb.Route{ |
| // A routing rule that can be selectively triggered based on properties about incoming RPC. |
| { |
| Match: &v3routepb.RouteMatch{ |
| PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: "/grpc.testing.TestService/EmptyCall"}, |
| // "Fully-qualified RPC method name with leading slash. Same as :path header". |
| }, |
| // Correct Action, so RPC's that match this route should proceed to interceptor processing. |
| Action: &v3routepb.Route_NonForwardingAction{}, |
| }, |
| // This routing rule is matched the same way as the one above, |
| // except has an incorrect action for the server side. However, |
| // since routing chooses the first route which matches an |
| // incoming RPC, this should never get invoked (iteration |
| // through this route slice is deterministic). |
| { |
| Match: &v3routepb.RouteMatch{ |
| PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: "/grpc.testing.TestService/EmptyCall"}, |
| // "Fully-qualified RPC method name with leading slash. Same as :path header". |
| }, |
| // Incorrect Action, so RPC's that match this route should get denied. |
| Action: &v3routepb.Route_Route{ |
| Route: &v3routepb.RouteAction{ClusterSpecifier: &v3routepb.RouteAction_Cluster{Cluster: ""}}, |
| }, |
| }, |
| // Another routing rule that can be selectively triggered based on incoming RPC. |
| { |
| Match: &v3routepb.RouteMatch{ |
| PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: "/grpc.testing.TestService/UnaryCall"}, |
| }, |
| // Wrong action (!Non_Forwarding_Action) so RPC's that match this route should get denied. |
| Action: &v3routepb.Route_Route{ |
| Route: &v3routepb.RouteAction{ClusterSpecifier: &v3routepb.RouteAction_Cluster{Cluster: ""}}, |
| }, |
| }, |
| // Another routing rule that can be selectively triggered based on incoming RPC. |
| { |
| Match: &v3routepb.RouteMatch{ |
| PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: "/grpc.testing.TestService/StreamingInputCall"}, |
| }, |
| // Wrong action (!Non_Forwarding_Action) so RPC's that match this route should get denied. |
| Action: &v3routepb.Route_Route{ |
| Route: &v3routepb.RouteAction{ClusterSpecifier: &v3routepb.RouteAction_Cluster{Cluster: ""}}, |
| }, |
| }, |
| // Not matching route, this is be able to get invoked logically (i.e. doesn't have to match the Route configurations above). |
| }}, |
| } |
| inboundLis := &v3listenerpb.Listener{ |
| Name: fmt.Sprintf(e2e.ServerListenerResourceNameTemplate, net.JoinHostPort(host, strconv.Itoa(int(port)))), |
| Address: &v3corepb.Address{ |
| Address: &v3corepb.Address_SocketAddress{ |
| SocketAddress: &v3corepb.SocketAddress{ |
| Address: host, |
| PortSpecifier: &v3corepb.SocketAddress_PortValue{ |
| PortValue: port, |
| }, |
| }, |
| }, |
| }, |
| FilterChains: []*v3listenerpb.FilterChain{ |
| { |
| Name: "v4-wildcard", |
| FilterChainMatch: &v3listenerpb.FilterChainMatch{ |
| PrefixRanges: []*v3corepb.CidrRange{ |
| { |
| AddressPrefix: "0.0.0.0", |
| PrefixLen: &wrapperspb.UInt32Value{ |
| Value: uint32(0), |
| }, |
| }, |
| }, |
| SourceType: v3listenerpb.FilterChainMatch_SAME_IP_OR_LOOPBACK, |
| SourcePrefixRanges: []*v3corepb.CidrRange{ |
| { |
| AddressPrefix: "0.0.0.0", |
| PrefixLen: &wrapperspb.UInt32Value{ |
| Value: uint32(0), |
| }, |
| }, |
| }, |
| }, |
| Filters: []*v3listenerpb.Filter{ |
| { |
| Name: "filter-1", |
| ConfigType: &v3listenerpb.Filter_TypedConfig{ |
| TypedConfig: testutils.MarshalAny(t, &v3httppb.HttpConnectionManager{ |
| HttpFilters: []*v3httppb.HttpFilter{e2e.HTTPFilter("router", &v3routerpb.Router{})}, |
| RouteSpecifier: &v3httppb.HttpConnectionManager_RouteConfig{ |
| RouteConfig: &v3routepb.RouteConfiguration{ |
| Name: "routeName", |
| VirtualHosts: vhs, |
| }, |
| }, |
| }), |
| }, |
| }, |
| }, |
| }, |
| { |
| Name: "v6-wildcard", |
| FilterChainMatch: &v3listenerpb.FilterChainMatch{ |
| PrefixRanges: []*v3corepb.CidrRange{ |
| { |
| AddressPrefix: "::", |
| PrefixLen: &wrapperspb.UInt32Value{ |
| Value: uint32(0), |
| }, |
| }, |
| }, |
| SourceType: v3listenerpb.FilterChainMatch_SAME_IP_OR_LOOPBACK, |
| SourcePrefixRanges: []*v3corepb.CidrRange{ |
| { |
| AddressPrefix: "::", |
| PrefixLen: &wrapperspb.UInt32Value{ |
| Value: uint32(0), |
| }, |
| }, |
| }, |
| }, |
| Filters: []*v3listenerpb.Filter{ |
| { |
| Name: "filter-1", |
| ConfigType: &v3listenerpb.Filter_TypedConfig{ |
| TypedConfig: testutils.MarshalAny(t, &v3httppb.HttpConnectionManager{ |
| HttpFilters: []*v3httppb.HttpFilter{e2e.HTTPFilter("router", &v3routerpb.Router{})}, |
| RouteSpecifier: &v3httppb.HttpConnectionManager_RouteConfig{ |
| RouteConfig: &v3routepb.RouteConfiguration{ |
| Name: "routeName", |
| VirtualHosts: vhs, |
| }, |
| }, |
| }), |
| }, |
| }, |
| }, |
| }, |
| }, |
| } |
| resources.Listeners = append(resources.Listeners, inboundLis) |
| |
| ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout) |
| defer cancel() |
| // Setup the management server with client and server-side resources. |
| if err := managementServer.Update(ctx, resources); err != nil { |
| t.Fatal(err) |
| } |
| |
| cc, err := grpc.NewClient(fmt.Sprintf("xds:///%s", serviceName), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithResolvers(xdsResolver)) |
| if err != nil { |
| t.Fatalf("grpc.NewClient() failed: %v", err) |
| } |
| defer cc.Close() |
| |
| client := testgrpc.NewTestServiceClient(cc) |
| |
| // This Empty Call should match to a route with a correct action |
| // (NonForwardingAction). Thus, this RPC should proceed as normal. There is |
| // a routing rule that this RPC would match to that has an incorrect action, |
| // but the server should only use the first route matched to with the |
| // correct action. |
| if _, err = client.EmptyCall(ctx, &testpb.Empty{}, grpc.WaitForReady(true)); err != nil { |
| t.Fatalf("rpc EmptyCall() failed: %v", err) |
| } |
| |
| // This Unary Call should match to a route with an incorrect action. Thus, |
| // this RPC should not go through as per A36, and this call should receive |
| // an error with codes.Unavailable. |
| _, err = client.UnaryCall(ctx, &testpb.SimpleRequest{}) |
| if status.Code(err) != codes.Unavailable { |
| t.Fatalf("client.UnaryCall() = _, %v, want _, error code %s", err, codes.Unavailable) |
| } |
| if !strings.Contains(err.Error(), nodeID) { |
| t.Fatalf("client.UnaryCall() = %v, want xDS node id %q", err, nodeID) |
| } |
| |
| // This Streaming Call should match to a route with an incorrect action. |
| // Thus, this RPC should not go through as per A36, and this call should |
| // receive an error with codes.Unavailable. |
| stream, err := client.StreamingInputCall(ctx) |
| if err != nil { |
| t.Fatalf("StreamingInputCall(_) = _, %v, want <nil>", err) |
| } |
| _, err = stream.CloseAndRecv() |
| const wantStreamingErr = "the incoming RPC matched to a route that was not of action type non forwarding" |
| if status.Code(err) != codes.Unavailable || !strings.Contains(err.Error(), wantStreamingErr) { |
| t.Fatalf("client.StreamingInputCall() = %v, want error with code %s and message %q", err, codes.Unavailable, wantStreamingErr) |
| } |
| if !strings.Contains(err.Error(), nodeID) { |
| t.Fatalf("client.StreamingInputCall() = %v, want xDS node id %q", err, nodeID) |
| } |
| |
| // This Full Duplex should not match to a route, and thus should return an |
| // error and not proceed. |
| dStream, err := client.FullDuplexCall(ctx) |
| if err != nil { |
| t.Fatalf("FullDuplexCall(_) = _, %v, want <nil>", err) |
| } |
| _, err = dStream.Recv() |
| const wantFullDuplexErr = "the incoming RPC did not match a configured Route" |
| if status.Code(err) != codes.Unavailable || !strings.Contains(err.Error(), wantFullDuplexErr) { |
| t.Fatalf("client.FullDuplexCall() = %v, want error with code %s and message %q", err, codes.Unavailable, wantFullDuplexErr) |
| } |
| if !strings.Contains(err.Error(), nodeID) { |
| t.Fatalf("client.FullDuplexCall() = %v, want xDS node id %q", err, nodeID) |
| } |
| } |
| |
| // serverListenerWithRBACHTTPFilters returns an xds Listener resource with HTTP Filters defined in the HCM, and a route |
| // configuration that always matches to a route and a VH. |
| func serverListenerWithRBACHTTPFilters(t *testing.T, host string, port uint32, rbacCfg *rpb.RBAC) *v3listenerpb.Listener { |
| // Rather than declare typed config inline, take a HCM proto and append the |
| // RBAC Filters to it. |
| hcm := &v3httppb.HttpConnectionManager{ |
| RouteSpecifier: &v3httppb.HttpConnectionManager_RouteConfig{ |
| RouteConfig: &v3routepb.RouteConfiguration{ |
| Name: "routeName", |
| VirtualHosts: []*v3routepb.VirtualHost{{ |
| Domains: []string{"*"}, |
| Routes: []*v3routepb.Route{{ |
| Match: &v3routepb.RouteMatch{ |
| PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: "/"}, |
| }, |
| Action: &v3routepb.Route_NonForwardingAction{}, |
| }}, |
| // This tests override parsing + building when RBAC Filter |
| // passed both normal and override config. |
| TypedPerFilterConfig: map[string]*anypb.Any{ |
| "rbac": testutils.MarshalAny(t, &rpb.RBACPerRoute{Rbac: rbacCfg}), |
| }, |
| }}}, |
| }, |
| } |
| hcm.HttpFilters = nil |
| hcm.HttpFilters = append(hcm.HttpFilters, e2e.HTTPFilter("rbac", rbacCfg)) |
| hcm.HttpFilters = append(hcm.HttpFilters, e2e.RouterHTTPFilter) |
| |
| return &v3listenerpb.Listener{ |
| Name: fmt.Sprintf(e2e.ServerListenerResourceNameTemplate, net.JoinHostPort(host, strconv.Itoa(int(port)))), |
| Address: &v3corepb.Address{ |
| Address: &v3corepb.Address_SocketAddress{ |
| SocketAddress: &v3corepb.SocketAddress{ |
| Address: host, |
| PortSpecifier: &v3corepb.SocketAddress_PortValue{ |
| PortValue: port, |
| }, |
| }, |
| }, |
| }, |
| FilterChains: []*v3listenerpb.FilterChain{ |
| { |
| Name: "v4-wildcard", |
| FilterChainMatch: &v3listenerpb.FilterChainMatch{ |
| PrefixRanges: []*v3corepb.CidrRange{ |
| { |
| AddressPrefix: "0.0.0.0", |
| PrefixLen: &wrapperspb.UInt32Value{ |
| Value: uint32(0), |
| }, |
| }, |
| }, |
| SourceType: v3listenerpb.FilterChainMatch_SAME_IP_OR_LOOPBACK, |
| SourcePrefixRanges: []*v3corepb.CidrRange{ |
| { |
| AddressPrefix: "0.0.0.0", |
| PrefixLen: &wrapperspb.UInt32Value{ |
| Value: uint32(0), |
| }, |
| }, |
| }, |
| }, |
| Filters: []*v3listenerpb.Filter{ |
| { |
| Name: "filter-1", |
| ConfigType: &v3listenerpb.Filter_TypedConfig{ |
| TypedConfig: testutils.MarshalAny(t, hcm), |
| }, |
| }, |
| }, |
| }, |
| { |
| Name: "v6-wildcard", |
| FilterChainMatch: &v3listenerpb.FilterChainMatch{ |
| PrefixRanges: []*v3corepb.CidrRange{ |
| { |
| AddressPrefix: "::", |
| PrefixLen: &wrapperspb.UInt32Value{ |
| Value: uint32(0), |
| }, |
| }, |
| }, |
| SourceType: v3listenerpb.FilterChainMatch_SAME_IP_OR_LOOPBACK, |
| SourcePrefixRanges: []*v3corepb.CidrRange{ |
| { |
| AddressPrefix: "::", |
| PrefixLen: &wrapperspb.UInt32Value{ |
| Value: uint32(0), |
| }, |
| }, |
| }, |
| }, |
| Filters: []*v3listenerpb.Filter{ |
| { |
| Name: "filter-1", |
| ConfigType: &v3listenerpb.Filter_TypedConfig{ |
| TypedConfig: testutils.MarshalAny(t, hcm), |
| }, |
| }, |
| }, |
| }, |
| }, |
| } |
| } |
| |
| // TestRBACHTTPFilter tests the xds configured RBAC HTTP Filter. It sets up the |
| // full end to end flow, and makes sure certain RPC's are successful and proceed |
| // as normal and certain RPC's are denied by the RBAC HTTP Filter which gets |
| // called by hooked xds interceptors. |
| func (s) TestRBACHTTPFilter(t *testing.T) { |
| tests := []struct { |
| name string |
| rbacCfg *rpb.RBAC |
| wantStatusEmptyCall codes.Code |
| wantStatusUnaryCall codes.Code |
| wantAuthzOutcomes map[bool]int |
| eventContent *audit.Event |
| }{ |
| // This test tests an RBAC HTTP Filter which is configured to allow any RPC. |
| // Any RPC passing through this RBAC HTTP Filter should proceed as normal. |
| { |
| name: "allow-anything", |
| rbacCfg: &rpb.RBAC{ |
| Rules: &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}}, |
| }, |
| }, |
| }, |
| AuditLoggingOptions: &v3rbacpb.RBAC_AuditLoggingOptions{ |
| AuditCondition: v3rbacpb.RBAC_AuditLoggingOptions_ON_ALLOW, |
| LoggerConfigs: []*v3rbacpb.RBAC_AuditLoggingOptions_AuditLoggerConfig{ |
| { |
| AuditLogger: &v3corepb.TypedExtensionConfig{ |
| Name: "stat_logger", |
| TypedConfig: createXDSTypedStruct(t, map[string]any{}, "stat_logger"), |
| }, |
| IsOptional: false, |
| }, |
| }, |
| }, |
| }, |
| }, |
| wantStatusEmptyCall: codes.OK, |
| wantStatusUnaryCall: codes.OK, |
| wantAuthzOutcomes: map[bool]int{true: 2, false: 0}, |
| // TODO(gtcooke94) add policy name (RBAC filter name) once |
| // https://github.com/grpc/grpc-go/pull/6327 is merged. |
| eventContent: &audit.Event{ |
| FullMethodName: "/grpc.testing.TestService/UnaryCall", |
| MatchedRule: "anyone", |
| Authorized: true, |
| }, |
| }, |
| // This test tests an RBAC HTTP Filter which is configured to allow only |
| // RPC's with certain paths ("UnaryCall"). Only unary calls passing |
| // through this RBAC HTTP Filter should proceed as normal, and any |
| // others should be denied. |
| { |
| name: "allow-certain-path", |
| rbacCfg: &rpb.RBAC{ |
| Rules: &v3rbacpb.RBAC{ |
| Action: v3rbacpb.RBAC_ALLOW, |
| Policies: map[string]*v3rbacpb.Policy{ |
| "certain-path": { |
| Permissions: []*v3rbacpb.Permission{ |
| {Rule: &v3rbacpb.Permission_UrlPath{UrlPath: &v3matcherpb.PathMatcher{Rule: &v3matcherpb.PathMatcher_Path{Path: &v3matcherpb.StringMatcher{MatchPattern: &v3matcherpb.StringMatcher_Exact{Exact: "/grpc.testing.TestService/UnaryCall"}}}}}}, |
| }, |
| Principals: []*v3rbacpb.Principal{ |
| {Identifier: &v3rbacpb.Principal_Any{Any: true}}, |
| }, |
| }, |
| }, |
| }, |
| }, |
| wantStatusEmptyCall: codes.PermissionDenied, |
| wantStatusUnaryCall: codes.OK, |
| }, |
| // This test tests an RBAC HTTP Filter which is configured to allow only |
| // RPC's with certain paths ("UnaryCall") via the ":path" header. Only |
| // unary calls passing through this RBAC HTTP Filter should proceed as |
| // normal, and any others should be denied. |
| { |
| name: "allow-certain-path-by-header", |
| rbacCfg: &rpb.RBAC{ |
| Rules: &v3rbacpb.RBAC{ |
| Action: v3rbacpb.RBAC_ALLOW, |
| Policies: map[string]*v3rbacpb.Policy{ |
| "certain-path": { |
| Permissions: []*v3rbacpb.Permission{ |
| {Rule: &v3rbacpb.Permission_Header{Header: &v3routepb.HeaderMatcher{Name: ":path", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "/grpc.testing.TestService/UnaryCall"}}}}, |
| }, |
| Principals: []*v3rbacpb.Principal{ |
| {Identifier: &v3rbacpb.Principal_Any{Any: true}}, |
| }, |
| }, |
| }, |
| }, |
| }, |
| wantStatusEmptyCall: codes.PermissionDenied, |
| wantStatusUnaryCall: codes.OK, |
| }, |
| // This test that a RBAC Config with nil rules means that every RPC is |
| // allowed. This maps to the line "If absent, no enforcing RBAC policy |
| // will be applied" from the RBAC Proto documentation for the Rules |
| // field. |
| { |
| name: "absent-rules", |
| rbacCfg: &rpb.RBAC{ |
| Rules: nil, |
| }, |
| wantStatusEmptyCall: codes.OK, |
| wantStatusUnaryCall: codes.OK, |
| }, |
| // The two tests below test that configuring the xDS RBAC HTTP Filter |
| // with :authority and host header matchers end up being logically |
| // equivalent. This represents functionality from this line in A41 - |
| // "As documented for HeaderMatcher, Envoy aliases :authority and Host |
| // in its header map implementation, so they should be treated |
| // equivalent for the RBAC matchers; there must be no behavior change |
| // depending on which of the two header names is used in the RBAC |
| // policy." |
| |
| // This test tests an xDS RBAC Filter with an :authority header matcher. |
| { |
| name: "match-on-authority", |
| rbacCfg: &rpb.RBAC{ |
| Rules: &v3rbacpb.RBAC{ |
| Action: v3rbacpb.RBAC_ALLOW, |
| Policies: map[string]*v3rbacpb.Policy{ |
| "match-on-authority": { |
| Permissions: []*v3rbacpb.Permission{ |
| {Rule: &v3rbacpb.Permission_Header{Header: &v3routepb.HeaderMatcher{Name: ":authority", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_PrefixMatch{PrefixMatch: "my-service-fallback"}}}}, |
| }, |
| Principals: []*v3rbacpb.Principal{ |
| {Identifier: &v3rbacpb.Principal_Any{Any: true}}, |
| }, |
| }, |
| }, |
| }, |
| }, |
| wantStatusEmptyCall: codes.OK, |
| wantStatusUnaryCall: codes.OK, |
| }, |
| // This test tests that configuring an xDS RBAC Filter with a host |
| // header matcher has the same behavior as if it was configured with |
| // :authority. Since host and authority are aliased, this should still |
| // continue to match on incoming RPC's :authority, just as the test |
| // above. |
| { |
| name: "match-on-host", |
| rbacCfg: &rpb.RBAC{ |
| Rules: &v3rbacpb.RBAC{ |
| Action: v3rbacpb.RBAC_ALLOW, |
| Policies: map[string]*v3rbacpb.Policy{ |
| "match-on-authority": { |
| Permissions: []*v3rbacpb.Permission{ |
| {Rule: &v3rbacpb.Permission_Header{Header: &v3routepb.HeaderMatcher{Name: "host", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_PrefixMatch{PrefixMatch: "my-service-fallback"}}}}, |
| }, |
| Principals: []*v3rbacpb.Principal{ |
| {Identifier: &v3rbacpb.Principal_Any{Any: true}}, |
| }, |
| }, |
| }, |
| }, |
| }, |
| wantStatusEmptyCall: codes.OK, |
| wantStatusUnaryCall: codes.OK, |
| }, |
| // This test tests that the RBAC HTTP Filter hard codes the :method |
| // header to POST. Since the RBAC Configuration says to deny every RPC |
| // with a method :POST, every RPC tried should be denied. |
| { |
| name: "deny-post", |
| rbacCfg: &rpb.RBAC{ |
| Rules: &v3rbacpb.RBAC{ |
| Action: v3rbacpb.RBAC_DENY, |
| Policies: map[string]*v3rbacpb.Policy{ |
| "post-method": { |
| Permissions: []*v3rbacpb.Permission{ |
| {Rule: &v3rbacpb.Permission_Header{Header: &v3routepb.HeaderMatcher{Name: ":method", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "POST"}}}}, |
| }, |
| Principals: []*v3rbacpb.Principal{ |
| {Identifier: &v3rbacpb.Principal_Any{Any: true}}, |
| }, |
| }, |
| }, |
| }, |
| }, |
| wantStatusEmptyCall: codes.PermissionDenied, |
| wantStatusUnaryCall: codes.PermissionDenied, |
| }, |
| { |
| name: "match-on-principal-remote-ip", |
| rbacCfg: &rpb.RBAC{ |
| Rules: &v3rbacpb.RBAC{ |
| Action: v3rbacpb.RBAC_ALLOW, |
| Policies: map[string]*v3rbacpb.Policy{ |
| "match-on-principal-remote-ip": { |
| Permissions: []*v3rbacpb.Permission{ |
| { |
| Rule: &v3rbacpb.Permission_Header{ |
| Header: &v3routepb.HeaderMatcher{ |
| Name: "host", |
| HeaderMatchSpecifier: &v3routepb.HeaderMatcher_PrefixMatch{PrefixMatch: "my-service-fallback"}, |
| }, |
| }, |
| }, |
| }, |
| Principals: []*v3rbacpb.Principal{ |
| { |
| Identifier: &v3rbacpb.Principal_RemoteIp{ |
| RemoteIp: &v3corepb.CidrRange{ |
| AddressPrefix: "127.0.0.0", |
| PrefixLen: &wrapperspb.UInt32Value{Value: 8}, |
| }, |
| }, |
| }, |
| { |
| Identifier: &v3rbacpb.Principal_RemoteIp{ |
| RemoteIp: &v3corepb.CidrRange{ |
| AddressPrefix: "::1", |
| PrefixLen: &wrapperspb.UInt32Value{Value: 128}, |
| }, |
| }, |
| }, |
| }, |
| }, |
| }, |
| }, |
| }, |
| wantStatusEmptyCall: codes.OK, |
| wantStatusUnaryCall: codes.OK, |
| }, |
| // This test tests that RBAC ignores the TE: trailers header (which is |
| // hardcoded in http2_client.go for every RPC). Since the RBAC |
| // Configuration says to only ALLOW RPC's with a TE: Trailers, every RPC |
| // tried should be denied. |
| { |
| name: "allow-only-te", |
| rbacCfg: &rpb.RBAC{ |
| Rules: &v3rbacpb.RBAC{ |
| Action: v3rbacpb.RBAC_ALLOW, |
| Policies: map[string]*v3rbacpb.Policy{ |
| "post-method": { |
| Permissions: []*v3rbacpb.Permission{ |
| {Rule: &v3rbacpb.Permission_Header{Header: &v3routepb.HeaderMatcher{Name: "TE", HeaderMatchSpecifier: &v3routepb.HeaderMatcher_ExactMatch{ExactMatch: "trailers"}}}}, |
| }, |
| Principals: []*v3rbacpb.Principal{ |
| {Identifier: &v3rbacpb.Principal_Any{Any: true}}, |
| }, |
| }, |
| }, |
| }, |
| }, |
| wantStatusEmptyCall: codes.PermissionDenied, |
| wantStatusUnaryCall: codes.PermissionDenied, |
| }, |
| // This test tests that an RBAC Config with Action.LOG configured allows |
| // every RPC through. This maps to the line "At this time, if the |
| // RBAC.action is Action.LOG then the policy will be completely ignored, |
| // as if RBAC was not configured." from A41 |
| { |
| name: "action-log", |
| rbacCfg: &rpb.RBAC{ |
| Rules: &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}}, |
| }, |
| }, |
| }, |
| }, |
| }, |
| wantStatusEmptyCall: codes.OK, |
| wantStatusUnaryCall: codes.OK, |
| }, |
| } |
| |
| for _, test := range tests { |
| t.Run(test.name, func(t *testing.T) { |
| func() { |
| lb := &loggerBuilder{ |
| authzDecisionStat: map[bool]int{true: 0, false: 0}, |
| lastEvent: &audit.Event{}, |
| } |
| audit.RegisterLoggerBuilder(lb) |
| |
| managementServer, nodeID, bootstrapContents, xdsResolver := setup.ManagementServerAndResolver(t) |
| |
| lis, cleanup2 := setupGRPCServer(t, bootstrapContents) |
| defer cleanup2() |
| |
| host, port, err := hostPortFromListener(lis) |
| if err != nil { |
| t.Fatalf("failed to retrieve host and port of server: %v", err) |
| } |
| const serviceName = "my-service-fallback" |
| resources := e2e.DefaultClientResources(e2e.ResourceParams{ |
| DialTarget: serviceName, |
| NodeID: nodeID, |
| Host: host, |
| Port: port, |
| SecLevel: e2e.SecurityLevelNone, |
| }) |
| inboundLis := serverListenerWithRBACHTTPFilters(t, host, port, test.rbacCfg) |
| resources.Listeners = append(resources.Listeners, inboundLis) |
| |
| ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout) |
| defer cancel() |
| // Setup the management server with client and server-side resources. |
| if err := managementServer.Update(ctx, resources); err != nil { |
| t.Fatal(err) |
| } |
| |
| cc, err := grpc.NewClient(fmt.Sprintf("xds:///%s", serviceName), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithResolvers(xdsResolver)) |
| if err != nil { |
| t.Fatalf("grpc.NewClient() failed: %v", err) |
| } |
| defer cc.Close() |
| |
| client := testgrpc.NewTestServiceClient(cc) |
| |
| if _, err := client.EmptyCall(ctx, &testpb.Empty{}, grpc.WaitForReady(true)); status.Code(err) != test.wantStatusEmptyCall { |
| t.Fatalf("EmptyCall() returned err with status: %v, wantStatusEmptyCall: %v", status.Code(err), test.wantStatusEmptyCall) |
| } |
| |
| if _, err := client.UnaryCall(ctx, &testpb.SimpleRequest{}); status.Code(err) != test.wantStatusUnaryCall { |
| t.Fatalf("UnaryCall() returned err with status: %v, wantStatusUnaryCall: %v", err, test.wantStatusUnaryCall) |
| } |
| |
| if test.wantAuthzOutcomes != nil { |
| if diff := cmp.Diff(lb.authzDecisionStat, test.wantAuthzOutcomes); diff != "" { |
| t.Fatalf("authorization decision do not match\ndiff (-got +want):\n%s", diff) |
| } |
| } |
| if test.eventContent != nil { |
| if diff := cmp.Diff(lb.lastEvent, test.eventContent); diff != "" { |
| t.Fatalf("unexpected event\ndiff (-got +want):\n%s", diff) |
| } |
| } |
| }() |
| }) |
| } |
| } |
| |
| // serverListenerWithBadRouteConfiguration returns an xds Listener resource with |
| // a Route Configuration that will never successfully match in order to test |
| // RBAC Environment variable being toggled on and off. |
| func serverListenerWithBadRouteConfiguration(t *testing.T, host string, port uint32) *v3listenerpb.Listener { |
| return &v3listenerpb.Listener{ |
| Name: fmt.Sprintf(e2e.ServerListenerResourceNameTemplate, net.JoinHostPort(host, strconv.Itoa(int(port)))), |
| Address: &v3corepb.Address{ |
| Address: &v3corepb.Address_SocketAddress{ |
| SocketAddress: &v3corepb.SocketAddress{ |
| Address: host, |
| PortSpecifier: &v3corepb.SocketAddress_PortValue{ |
| PortValue: port, |
| }, |
| }, |
| }, |
| }, |
| FilterChains: []*v3listenerpb.FilterChain{ |
| { |
| Name: "v4-wildcard", |
| FilterChainMatch: &v3listenerpb.FilterChainMatch{ |
| PrefixRanges: []*v3corepb.CidrRange{ |
| { |
| AddressPrefix: "0.0.0.0", |
| PrefixLen: &wrapperspb.UInt32Value{ |
| Value: uint32(0), |
| }, |
| }, |
| }, |
| SourceType: v3listenerpb.FilterChainMatch_SAME_IP_OR_LOOPBACK, |
| SourcePrefixRanges: []*v3corepb.CidrRange{ |
| { |
| AddressPrefix: "0.0.0.0", |
| PrefixLen: &wrapperspb.UInt32Value{ |
| Value: uint32(0), |
| }, |
| }, |
| }, |
| }, |
| Filters: []*v3listenerpb.Filter{ |
| { |
| Name: "filter-1", |
| ConfigType: &v3listenerpb.Filter_TypedConfig{ |
| TypedConfig: testutils.MarshalAny(t, &v3httppb.HttpConnectionManager{ |
| RouteSpecifier: &v3httppb.HttpConnectionManager_RouteConfig{ |
| RouteConfig: &v3routepb.RouteConfiguration{ |
| Name: "routeName", |
| VirtualHosts: []*v3routepb.VirtualHost{{ |
| // Incoming RPC's will try and match to Virtual Hosts based on their :authority header. |
| // Thus, incoming RPC's will never match to a Virtual Host (server side requires matching |
| // to a VH/Route of type Non Forwarding Action to proceed normally), and all incoming RPC's |
| // with this route configuration will be denied. |
| Domains: []string{"will-never-match"}, |
| Routes: []*v3routepb.Route{{ |
| Match: &v3routepb.RouteMatch{ |
| PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: "/"}, |
| }, |
| Action: &v3routepb.Route_NonForwardingAction{}, |
| }}}}}, |
| }, |
| HttpFilters: []*v3httppb.HttpFilter{e2e.RouterHTTPFilter}, |
| }), |
| }, |
| }, |
| }, |
| }, |
| { |
| Name: "v6-wildcard", |
| FilterChainMatch: &v3listenerpb.FilterChainMatch{ |
| PrefixRanges: []*v3corepb.CidrRange{ |
| { |
| AddressPrefix: "::", |
| PrefixLen: &wrapperspb.UInt32Value{ |
| Value: uint32(0), |
| }, |
| }, |
| }, |
| SourceType: v3listenerpb.FilterChainMatch_SAME_IP_OR_LOOPBACK, |
| SourcePrefixRanges: []*v3corepb.CidrRange{ |
| { |
| AddressPrefix: "::", |
| PrefixLen: &wrapperspb.UInt32Value{ |
| Value: uint32(0), |
| }, |
| }, |
| }, |
| }, |
| Filters: []*v3listenerpb.Filter{ |
| { |
| Name: "filter-1", |
| ConfigType: &v3listenerpb.Filter_TypedConfig{ |
| TypedConfig: testutils.MarshalAny(t, &v3httppb.HttpConnectionManager{ |
| RouteSpecifier: &v3httppb.HttpConnectionManager_RouteConfig{ |
| RouteConfig: &v3routepb.RouteConfiguration{ |
| Name: "routeName", |
| VirtualHosts: []*v3routepb.VirtualHost{{ |
| // Incoming RPC's will try and match to Virtual Hosts based on their :authority header. |
| // Thus, incoming RPC's will never match to a Virtual Host (server side requires matching |
| // to a VH/Route of type Non Forwarding Action to proceed normally), and all incoming RPC's |
| // with this route configuration will be denied. |
| Domains: []string{"will-never-match"}, |
| Routes: []*v3routepb.Route{{ |
| Match: &v3routepb.RouteMatch{ |
| PathSpecifier: &v3routepb.RouteMatch_Prefix{Prefix: "/"}, |
| }, |
| Action: &v3routepb.Route_NonForwardingAction{}, |
| }}}}}, |
| }, |
| HttpFilters: []*v3httppb.HttpFilter{e2e.RouterHTTPFilter}, |
| }), |
| }, |
| }, |
| }, |
| }, |
| }, |
| } |
| } |
| |
| func (s) TestRBAC_WithBadRouteConfiguration(t *testing.T) { |
| managementServer, nodeID, bootstrapContents, xdsResolver := setup.ManagementServerAndResolver(t) |
| // We need to wait for the server to enter SERVING mode before making RPCs |
| // to avoid flakes due to the server closing connections. |
| servingCh := make(chan struct{}) |
| |
| // Initialize a test gRPC server, assign it to the stub server, and start |
| // the test service. |
| opt := xds.ServingModeCallback(func(_ net.Addr, args xds.ServingModeChangeArgs) { |
| if args.Mode == connectivity.ServingModeServing { |
| close(servingCh) |
| } |
| }) |
| lis, cleanup2 := setupGRPCServer(t, bootstrapContents, opt) |
| defer cleanup2() |
| |
| ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout) |
| defer cancel() |
| |
| host, port, err := hostPortFromListener(lis) |
| if err != nil { |
| t.Fatalf("failed to retrieve host and port of server: %v", err) |
| } |
| const serviceName = "my-service-fallback" |
| |
| // The inbound listener needs a route table that will never match on a VH, |
| // and thus shouldn't allow incoming RPC's to proceed. |
| resources := e2e.DefaultClientResources(e2e.ResourceParams{ |
| DialTarget: serviceName, |
| NodeID: nodeID, |
| Host: host, |
| Port: port, |
| SecLevel: e2e.SecurityLevelNone, |
| }) |
| // Since RBAC support is turned ON, all the RPC's should get denied with |
| // status code Unavailable due to not matching to a route of type Non |
| // Forwarding Action (Route Table not configured properly). |
| inboundLis := serverListenerWithBadRouteConfiguration(t, host, port) |
| resources.Listeners = append(resources.Listeners, inboundLis) |
| |
| // Setup the management server with client and server-side resources. |
| if err := managementServer.Update(ctx, resources); err != nil { |
| t.Fatal(err) |
| } |
| |
| select { |
| case <-ctx.Done(): |
| t.Fatal("Timeout waiting for the xDS-enabled gRPC server to go SERVING") |
| case <-servingCh: |
| } |
| |
| cc, err := grpc.NewClient(fmt.Sprintf("xds:///%s", serviceName), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithResolvers(xdsResolver)) |
| if err != nil { |
| t.Fatalf("grpc.NewClient() failed: %v", err) |
| } |
| defer cc.Close() |
| |
| client := testgrpc.NewTestServiceClient(cc) |
| _, err = client.EmptyCall(ctx, &testpb.Empty{}) |
| if status.Code(err) != codes.Unavailable { |
| t.Fatalf("EmptyCall() returned %v, want Unavailable", err) |
| } |
| if !strings.Contains(err.Error(), nodeID) { |
| t.Fatalf("EmptyCall() = %v, want xDS node id %q", err, nodeID) |
| } |
| _, err = client.UnaryCall(ctx, &testpb.SimpleRequest{}) |
| if status.Code(err) != codes.Unavailable { |
| t.Fatalf("UnaryCall() returned %v, want Unavailable", err) |
| } |
| if !strings.Contains(err.Error(), nodeID) { |
| t.Fatalf("UnaryCall() = %v, want xDS node id %q", err, nodeID) |
| } |
| } |
| |
| type statAuditLogger struct { |
| authzDecisionStat map[bool]int // Map to hold counts of authorization decisions |
| lastEvent *audit.Event // Field to store last received event |
| } |
| |
| func (s *statAuditLogger) Log(event *audit.Event) { |
| s.authzDecisionStat[event.Authorized]++ |
| *s.lastEvent = *event |
| } |
| |
| type loggerBuilder struct { |
| authzDecisionStat map[bool]int |
| lastEvent *audit.Event |
| } |
| |
| func (loggerBuilder) Name() string { |
| return "stat_logger" |
| } |
| |
| func (lb *loggerBuilder) Build(audit.LoggerConfig) audit.Logger { |
| return &statAuditLogger{ |
| authzDecisionStat: lb.authzDecisionStat, |
| lastEvent: lb.lastEvent, |
| } |
| } |
| |
| func (*loggerBuilder) ParseLoggerConfig(json.RawMessage) (audit.LoggerConfig, error) { |
| return nil, nil |
| } |
| |
| // This is used when converting a custom config from raw JSON to a TypedStruct. |
| // The TypeURL of the TypeStruct will be "grpc.authz.audit_logging/<name>". |
| const typeURLPrefix = "grpc.authz.audit_logging/" |
| |
| // Builds custom configs for audit logger RBAC protos. |
| func createXDSTypedStruct(t *testing.T, in map[string]any, name string) *anypb.Any { |
| t.Helper() |
| pb, err := structpb.NewStruct(in) |
| if err != nil { |
| t.Fatalf("createXDSTypedStruct failed during structpb.NewStruct: %v", err) |
| } |
| typedStruct := &v3xdsxdstypepb.TypedStruct{ |
| TypeUrl: typeURLPrefix + name, |
| Value: pb, |
| } |
| customConfig, err := anypb.New(typedStruct) |
| if err != nil { |
| t.Fatalf("createXDSTypedStruct failed during anypb.New: %v", err) |
| } |
| return customConfig |
| } |