| /* |
| * Copyright 2020 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 engine |
| |
| import ( |
| "context" |
| "reflect" |
| "sort" |
| "testing" |
| |
| pb "github.com/envoyproxy/go-control-plane/envoy/config/rbac/v2" |
| "github.com/google/cel-go/cel" |
| "github.com/google/cel-go/checker/decls" |
| "github.com/google/cel-go/common/types" |
| "github.com/google/cel-go/common/types/ref" |
| "github.com/google/cel-go/interpreter" |
| "github.com/google/go-cmp/cmp" |
| expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1" |
| "google.golang.org/grpc/codes" |
| "google.golang.org/grpc/internal/grpctest" |
| "google.golang.org/grpc/peer" |
| "google.golang.org/grpc/status" |
| ) |
| |
| type s struct { |
| grpctest.Tester |
| } |
| |
| type fakeProgram struct { |
| out ref.Val |
| err error |
| } |
| |
| func (fake fakeProgram) Eval(vars interface{}) (ref.Val, *cel.EvalDetails, error) { |
| return fake.out, nil, fake.err |
| } |
| |
| func (fake fakeProgram) ContextEval(ctx context.Context, vars interface{}) (ref.Val, *cel.EvalDetails, error) { |
| return fake.Eval(vars) |
| } |
| |
| type valMock struct { |
| val interface{} |
| } |
| |
| func (mock valMock) ConvertToNative(typeDesc reflect.Type) (interface{}, error) { |
| return nil, nil |
| } |
| |
| func (mock valMock) ConvertToType(typeValue ref.Type) ref.Val { |
| return nil |
| } |
| |
| func (mock valMock) Equal(other ref.Val) ref.Val { |
| return nil |
| } |
| |
| func (mock valMock) Type() ref.Type { |
| if mock.val == true || mock.val == false { |
| return types.BoolType |
| } |
| return nil |
| } |
| |
| func (mock valMock) Value() interface{} { |
| return mock.val |
| } |
| |
| type addrMock struct { |
| addr string |
| } |
| |
| func (mock addrMock) Network() string { |
| return "tcp" |
| } |
| |
| func (mock addrMock) String() string { |
| return mock.addr |
| } |
| |
| var ( |
| emptyActivation = interpreter.EmptyActivation() |
| unsuccessfulProgram = fakeProgram{out: nil, err: status.Errorf(codes.InvalidArgument, "Unsuccessful program evaluation")} |
| errProgram = fakeProgram{out: valMock{"missing attributes"}, err: status.Errorf(codes.InvalidArgument, "Successful program evaluation to an error result -- missing attributes")} |
| trueProgram = fakeProgram{out: valMock{true}, err: nil} |
| falseProgram = fakeProgram{out: valMock{false}, err: nil} |
| |
| allowMatchEngine = &policyEngine{action: pb.RBAC_ALLOW, programs: map[string]cel.Program{ |
| "allow match policy1": unsuccessfulProgram, |
| "allow match policy2": trueProgram, |
| "allow match policy3": falseProgram, |
| "allow match policy4": errProgram, |
| }} |
| denyFailEngine = &policyEngine{action: pb.RBAC_DENY, programs: map[string]cel.Program{ |
| "deny fail policy1": falseProgram, |
| "deny fail policy2": falseProgram, |
| "deny fail policy3": falseProgram, |
| }} |
| denyUnknownEngine = &policyEngine{action: pb.RBAC_DENY, programs: map[string]cel.Program{ |
| "deny unknown policy1": falseProgram, |
| "deny unknown policy2": unsuccessfulProgram, |
| "deny unknown policy3": errProgram, |
| "deny unknown policy4": falseProgram, |
| }} |
| ) |
| |
| func Test(t *testing.T) { |
| grpctest.RunSubTests(t, s{}) |
| } |
| |
| func (s) TestNewAuthorizationEngine(t *testing.T) { |
| tests := map[string]struct { |
| allow *pb.RBAC |
| deny *pb.RBAC |
| wantErr bool |
| errStr string |
| }{ |
| "too few rbacs": { |
| wantErr: true, |
| errStr: "Expected error: at least one of allow, deny must be non-nil", |
| }, |
| "one rbac allow": { |
| allow: &pb.RBAC{Action: pb.RBAC_ALLOW}, |
| errStr: "Expected 1 ALLOW RBAC to be successful", |
| }, |
| "one rbac deny": { |
| deny: &pb.RBAC{Action: pb.RBAC_DENY}, |
| errStr: "Expected 1 DENY RBAC to be successful", |
| }, |
| "two rbacs": { |
| allow: &pb.RBAC{Action: pb.RBAC_ALLOW}, |
| deny: &pb.RBAC{Action: pb.RBAC_DENY}, |
| errStr: "Expected 2 RBACs (DENY + ALLOW) to be successful", |
| }, |
| "wrong rbac actions": { |
| allow: &pb.RBAC{Action: pb.RBAC_DENY}, |
| wantErr: true, |
| errStr: "Expected error: allow must have action ALLOW, deny must have action DENY", |
| }, |
| } |
| |
| for name, tc := range tests { |
| t.Run(name, func(t *testing.T) { |
| _, gotErr := NewAuthorizationEngine(tc.allow, tc.deny) |
| if (gotErr != nil) != tc.wantErr { |
| t.Fatal(tc.errStr) |
| } |
| }) |
| } |
| } |
| |
| func (s) TestGetDecision(t *testing.T) { |
| tests := map[string]struct { |
| engine *policyEngine |
| match bool |
| want Decision |
| }{ |
| "ALLOW engine match": { |
| engine: &policyEngine{action: pb.RBAC_ALLOW, programs: map[string]cel.Program{}}, |
| match: true, |
| want: DecisionAllow, |
| }, |
| "ALLOW engine fail": { |
| engine: &policyEngine{action: pb.RBAC_ALLOW, programs: map[string]cel.Program{}}, |
| want: DecisionDeny, |
| }, |
| "DENY engine match": { |
| engine: &policyEngine{action: pb.RBAC_DENY, programs: map[string]cel.Program{}}, |
| match: true, |
| want: DecisionDeny, |
| }, |
| "DENY engine fail": { |
| engine: &policyEngine{action: pb.RBAC_DENY, programs: map[string]cel.Program{}}, |
| want: DecisionAllow, |
| }, |
| } |
| |
| for name, tc := range tests { |
| t.Run(name, func(t *testing.T) { |
| if got := getDecision(tc.engine, tc.match); got != tc.want { |
| t.Fatalf("getDecision(%v, %v) = (%v), want (%v)", tc.engine, tc.match, got, tc.want) |
| } |
| }) |
| } |
| } |
| |
| func (s) TestPolicyEngineEvaluate(t *testing.T) { |
| tests := map[string]struct { |
| engine *policyEngine |
| activation interpreter.Activation |
| wantDecision Decision |
| wantPolicyNames []string |
| }{ |
| "no policies": { |
| engine: &policyEngine{}, |
| activation: emptyActivation, |
| wantDecision: DecisionDeny, |
| wantPolicyNames: []string{}, |
| }, |
| "match succeed": { |
| engine: allowMatchEngine, |
| activation: emptyActivation, |
| wantDecision: DecisionAllow, |
| wantPolicyNames: []string{"allow match policy2"}, |
| }, |
| "match fail": { |
| engine: denyFailEngine, |
| activation: emptyActivation, |
| wantDecision: DecisionAllow, |
| wantPolicyNames: []string{}, |
| }, |
| "unknown": { |
| engine: denyUnknownEngine, |
| activation: emptyActivation, |
| wantDecision: DecisionUnknown, |
| wantPolicyNames: []string{"deny unknown policy2", "deny unknown policy3"}, |
| }, |
| } |
| |
| for name, tc := range tests { |
| t.Run(name, func(t *testing.T) { |
| gotDecision, gotPolicyNames := tc.engine.evaluate(tc.activation) |
| sort.Strings(gotPolicyNames) |
| if gotDecision != tc.wantDecision || !cmp.Equal(gotPolicyNames, tc.wantPolicyNames) { |
| t.Fatalf("policyEngine.evaluate(%v, %v) = (%v, %v), want (%v, %v)", tc.engine, tc.activation, gotDecision, gotPolicyNames, tc.wantDecision, tc.wantPolicyNames) |
| } |
| }) |
| } |
| } |
| |
| func (s) TestAuthorizationEngineEvaluate(t *testing.T) { |
| tests := map[string]struct { |
| engine *AuthorizationEngine |
| authArgs *AuthorizationArgs |
| wantAuthDecision *AuthorizationDecision |
| }{ |
| "allow match": { |
| engine: &AuthorizationEngine{allow: allowMatchEngine}, |
| authArgs: &AuthorizationArgs{}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionAllow, policyNames: []string{"allow match policy2"}}, |
| }, |
| "deny fail": { |
| engine: &AuthorizationEngine{deny: denyFailEngine}, |
| authArgs: &AuthorizationArgs{}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionAllow, policyNames: []string{}}, |
| }, |
| "first engine unknown": { |
| engine: &AuthorizationEngine{allow: allowMatchEngine, deny: denyUnknownEngine}, |
| authArgs: &AuthorizationArgs{}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionUnknown, policyNames: []string{"deny unknown policy2", "deny unknown policy3"}}, |
| }, |
| "second engine match": { |
| engine: &AuthorizationEngine{allow: allowMatchEngine, deny: denyFailEngine}, |
| authArgs: &AuthorizationArgs{}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionAllow, policyNames: []string{"allow match policy2"}}, |
| }, |
| } |
| |
| for name, tc := range tests { |
| t.Run(name, func(t *testing.T) { |
| gotAuthDecision, gotErr := tc.engine.Evaluate(tc.authArgs) |
| sort.Strings(gotAuthDecision.policyNames) |
| if gotErr != nil || gotAuthDecision.decision != tc.wantAuthDecision.decision || !cmp.Equal(gotAuthDecision.policyNames, tc.wantAuthDecision.policyNames) { |
| t.Fatalf("AuthorizationEngine.Evaluate(%v, %v) = (%v, %v), want (%v, %v)", tc.engine, tc.authArgs, gotAuthDecision, gotErr, tc.wantAuthDecision, nil) |
| } |
| }) |
| } |
| } |
| |
| func (s) TestIntegration(t *testing.T) { |
| declarations := []*expr.Decl{ |
| decls.NewVar("request.url_path", decls.String), |
| decls.NewVar("request.host", decls.String), |
| decls.NewVar("request.method", decls.String), |
| decls.NewVar("request.headers", decls.NewMapType(decls.String, decls.String)), |
| decls.NewVar("source.address", decls.String), |
| decls.NewVar("source.port", decls.Int), |
| decls.NewVar("destination.address", decls.String), |
| decls.NewVar("destination.port", decls.Int), |
| decls.NewVar("connection.uri_san_peer_certificate", decls.String), |
| decls.NewVar("source.principal", decls.String), |
| } |
| |
| tests := map[string]struct { |
| allow *pb.RBAC |
| deny *pb.RBAC |
| authArgs *AuthorizationArgs |
| wantAuthDecision *AuthorizationDecision |
| }{ |
| "ALLOW engine: DecisionAllow": { |
| allow: &pb.RBAC{Action: pb.RBAC_ALLOW, Policies: map[string]*pb.Policy{ |
| "url_path starts with": {Condition: compileStringToExpr("request.url_path.startsWith('/pkg.service/test')", declarations)}, |
| }}, |
| authArgs: &AuthorizationArgs{fullMethod: "/pkg.service/test/method"}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionAllow, policyNames: []string{"url_path starts with"}}, |
| }, |
| "ALLOW engine: DecisionUnknown": { |
| allow: &pb.RBAC{Action: pb.RBAC_ALLOW, Policies: map[string]*pb.Policy{ |
| "url_path and uri_san_peer_certificate": {Condition: compileStringToExpr("request.url_path == '/pkg.service/test' && connection.uri_san_peer_certificate == 'cluster/ns/default/sa/admin'", declarations)}, |
| "source port": {Condition: compileStringToExpr("source.port == 8080", declarations)}, |
| }}, |
| authArgs: &AuthorizationArgs{peerInfo: &peer.Peer{Addr: addrMock{addr: "192.0.2.1:25"}}, fullMethod: "/pkg.service/test"}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionUnknown, policyNames: []string{"url_path and uri_san_peer_certificate"}}, |
| }, |
| "ALLOW engine: DecisionDeny": { |
| allow: &pb.RBAC{Action: pb.RBAC_ALLOW, Policies: map[string]*pb.Policy{ |
| "url_path": {Condition: compileStringToExpr("request.url_path == '/pkg.service/test'", declarations)}, |
| }}, |
| authArgs: &AuthorizationArgs{fullMethod: "/pkg.service/test/method"}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionDeny, policyNames: []string{}}, |
| }, |
| "DENY engine: DecisionAllow": { |
| deny: &pb.RBAC{Action: pb.RBAC_DENY, Policies: map[string]*pb.Policy{ |
| "url_path and uri_san_peer_certificate": {Condition: compileStringToExpr("request.url_path == '/pkg.service/test' && connection.uri_san_peer_certificate == 'cluster/ns/default/sa/admin'", declarations)}, |
| }}, |
| authArgs: &AuthorizationArgs{fullMethod: "/pkg.service/test/method"}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionAllow, policyNames: []string{}}, |
| }, |
| "DENY engine: DecisionUnknown": { |
| deny: &pb.RBAC{Action: pb.RBAC_DENY, Policies: map[string]*pb.Policy{ |
| "destination address": {Condition: compileStringToExpr("destination.address == '192.0.3.1'", declarations)}, |
| "source port": {Condition: compileStringToExpr("source.port == 8080", declarations)}, |
| }}, |
| authArgs: &AuthorizationArgs{peerInfo: &peer.Peer{Addr: addrMock{addr: "192.0.2.1:25"}}, fullMethod: "/pkg.service/test"}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionUnknown, policyNames: []string{"destination address"}}, |
| }, |
| "DENY engine: DecisionDeny": { |
| deny: &pb.RBAC{Action: pb.RBAC_DENY, Policies: map[string]*pb.Policy{ |
| "destination address": {Condition: compileStringToExpr("destination.address == '192.0.3.1'", declarations)}, |
| "source address or source port": {Condition: compileStringToExpr("source.address == '192.0.4.1' || source.port == 8080", declarations)}, |
| }}, |
| authArgs: &AuthorizationArgs{peerInfo: &peer.Peer{Addr: addrMock{addr: "192.0.2.1:8080"}}, fullMethod: "/pkg.service/test"}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionDeny, policyNames: []string{"source address or source port"}}, |
| }, |
| "DENY ALLOW engine: DecisionDeny from DENY policy": { |
| allow: &pb.RBAC{Action: pb.RBAC_ALLOW, Policies: map[string]*pb.Policy{ |
| "url_path starts with": {Condition: compileStringToExpr("request.url_path.startsWith('/pkg.service/test')", declarations)}, |
| }}, |
| deny: &pb.RBAC{Action: pb.RBAC_DENY, Policies: map[string]*pb.Policy{ |
| "destination address": {Condition: compileStringToExpr("destination.address == '192.0.3.1'", declarations)}, |
| "source address or source port": {Condition: compileStringToExpr("source.address == '192.0.4.1' || source.port == 8080", declarations)}, |
| }}, |
| authArgs: &AuthorizationArgs{peerInfo: &peer.Peer{Addr: addrMock{addr: "192.0.2.1:8080"}}, fullMethod: "/pkg.service/test"}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionDeny, policyNames: []string{"source address or source port"}}, |
| }, |
| "DENY ALLOW engine: DecisionUnknown from DENY policy": { |
| allow: &pb.RBAC{Action: pb.RBAC_ALLOW, Policies: map[string]*pb.Policy{ |
| "url_path starts with": {Condition: compileStringToExpr("request.url_path.startsWith('/pkg.service/test')", declarations)}, |
| }}, |
| deny: &pb.RBAC{Action: pb.RBAC_DENY, Policies: map[string]*pb.Policy{ |
| "destination address": {Condition: compileStringToExpr("destination.address == '192.0.3.1'", declarations)}, |
| "source port and destination port": {Condition: compileStringToExpr("source.port == 8080 && destination.port == 1234", declarations)}, |
| }}, |
| authArgs: &AuthorizationArgs{peerInfo: &peer.Peer{Addr: addrMock{addr: "192.0.2.1:8080"}}, fullMethod: "/pkg.service/test/method"}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionUnknown, policyNames: []string{"destination address", "source port and destination port"}}, |
| }, |
| "DENY ALLOW engine: DecisionAllow from ALLOW policy": { |
| allow: &pb.RBAC{Action: pb.RBAC_ALLOW, Policies: map[string]*pb.Policy{ |
| "method or url_path starts with": {Condition: compileStringToExpr("request.method == 'POST' || request.url_path.startsWith('/pkg.service/test')", declarations)}, |
| }}, |
| deny: &pb.RBAC{Action: pb.RBAC_DENY, Policies: map[string]*pb.Policy{ |
| "source address": {Condition: compileStringToExpr("source.address == '192.0.3.1'", declarations)}, |
| "source port and url_path": {Condition: compileStringToExpr("source.port == 8080 && request.url_path == 'pkg.service/test'", declarations)}, |
| }}, |
| authArgs: &AuthorizationArgs{peerInfo: &peer.Peer{Addr: addrMock{addr: "192.0.2.1:8080"}}, fullMethod: "/pkg.service/test/method"}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionAllow, policyNames: []string{"method or url_path starts with"}}, |
| }, |
| "DENY ALLOW engine: DecisionUnknown from ALLOW policy": { |
| allow: &pb.RBAC{Action: pb.RBAC_ALLOW, Policies: map[string]*pb.Policy{ |
| "url_path starts with and method": {Condition: compileStringToExpr("request.url_path.startsWith('/pkg.service/test') && request.method == 'POST'", declarations)}, |
| }}, |
| deny: &pb.RBAC{Action: pb.RBAC_DENY, Policies: map[string]*pb.Policy{ |
| "source address": {Condition: compileStringToExpr("source.address == '192.0.3.1'", declarations)}, |
| "source port and url_path": {Condition: compileStringToExpr("source.port == 8080 && request.url_path == 'pkg.service/test'", declarations)}, |
| }}, |
| authArgs: &AuthorizationArgs{peerInfo: &peer.Peer{Addr: addrMock{addr: "192.0.2.1:8080"}}, fullMethod: "/pkg.service/test/method"}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionUnknown, policyNames: []string{"url_path starts with and method"}}, |
| }, |
| "DENY ALLOW engine: DecisionDeny from ALLOW policy": { |
| allow: &pb.RBAC{Action: pb.RBAC_ALLOW, Policies: map[string]*pb.Policy{ |
| "url_path starts with and source port": {Condition: compileStringToExpr("request.url_path.startsWith('/pkg.service/test') && source.port == 1234", declarations)}, |
| }}, |
| deny: &pb.RBAC{Action: pb.RBAC_DENY, Policies: map[string]*pb.Policy{ |
| "source address": {Condition: compileStringToExpr("source.address == '192.0.3.1'", declarations)}, |
| "source port and url_path": {Condition: compileStringToExpr("source.port == 8080 && request.url_path == 'pkg.service/test'", declarations)}, |
| }}, |
| authArgs: &AuthorizationArgs{peerInfo: &peer.Peer{Addr: addrMock{addr: "192.0.2.1:8080"}}, fullMethod: "/pkg.service/test/method"}, |
| wantAuthDecision: &AuthorizationDecision{decision: DecisionDeny, policyNames: []string{}}, |
| }, |
| } |
| |
| for name, tc := range tests { |
| t.Run(name, func(t *testing.T) { |
| engine, err := NewAuthorizationEngine(tc.allow, tc.deny) |
| if err != nil { |
| t.Fatalf("Error constructing authorization engine: %v", err) |
| } |
| gotAuthDecision, gotErr := engine.Evaluate(tc.authArgs) |
| sort.Strings(gotAuthDecision.policyNames) |
| if gotErr != nil || gotAuthDecision.decision != tc.wantAuthDecision.decision || !cmp.Equal(gotAuthDecision.policyNames, tc.wantAuthDecision.policyNames) { |
| t.Fatalf("NewAuthorizationEngine(%v, %v).Evaluate(%v) = (%v, %v), want (%v, %v)", tc.allow, tc.deny, tc.authArgs, gotAuthDecision, gotErr, tc.wantAuthDecision, nil) |
| } |
| }) |
| } |
| } |