From 5377eacd92b3936bd4cb6c61d8b6c903081fc7a5 Mon Sep 17 00:00:00 2001 From: rchowinfoblox Date: Mon, 25 Nov 2024 10:40:56 -0800 Subject: [PATCH] PTCI-830: Add FilterCompartmentPerm/Feat, GetCurrUserCompartments to main (v1) branch --- go.mod | 2 +- go.sum | 4 +- grpc_opa/authorizer.go | 65 +-- grpc_opa/compartments.go | 61 +++ grpc_opa/compartments_test.go | 193 +++++++++ grpc_opa/filter_compartment_perms_feats.go | 130 ++++++ .../filter_compartment_perms_feats_test.go | 394 ++++++++++++++++++ grpc_opa/options.go | 14 + grpc_opa/testdata/mock_authz_policy.rego | 210 ++++++++++ utils_test/jwt.go | 18 + utils_test/mock_opa_client.go | 46 ++ 11 files changed, 1108 insertions(+), 29 deletions(-) create mode 100644 grpc_opa/compartments.go create mode 100644 grpc_opa/compartments_test.go create mode 100644 grpc_opa/filter_compartment_perms_feats.go create mode 100644 grpc_opa/filter_compartment_perms_feats_test.go create mode 100644 utils_test/jwt.go create mode 100644 utils_test/mock_opa_client.go diff --git a/go.mod b/go.mod index 9ed4e9a..47a55e5 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.17 require ( github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/infobloxopen/atlas-app-toolkit v1.1.2 - github.com/infobloxopen/atlas-claims v1.0.0 + github.com/infobloxopen/atlas-claims v1.1.2 github.com/infobloxopen/seal v0.2.3 github.com/open-policy-agent/opa v0.37.2 github.com/sirupsen/logrus v1.8.1 diff --git a/go.sum b/go.sum index a7f86aa..373a300 100644 --- a/go.sum +++ b/go.sum @@ -317,8 +317,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1: github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/infobloxopen/atlas-app-toolkit v1.1.2 h1:bB7vWc2mJnqmk+RU594L1JM8fJOQMu6MHy3Uglo0SUQ= github.com/infobloxopen/atlas-app-toolkit v1.1.2/go.mod h1:vd1L67El5az4iEwBK5wqDt+VRDNUpDtVqJCYftnJ8S0= -github.com/infobloxopen/atlas-claims v1.0.0 h1:uGwFxbEGDZSql3ePeH/z/TA14IInGBNOkzOOGNrdrBI= -github.com/infobloxopen/atlas-claims v1.0.0/go.mod h1:6aN87f8OZRqQZ6abcI6tbHiXnE5QiTyNVd31Cb67hy8= +github.com/infobloxopen/atlas-claims v1.1.2 h1:IzKTrRYuXuaBL3gIIffaLT1rOMM7k0ApOPlI6MyP2pE= +github.com/infobloxopen/atlas-claims v1.1.2/go.mod h1:6aN87f8OZRqQZ6abcI6tbHiXnE5QiTyNVd31Cb67hy8= github.com/infobloxopen/seal v0.2.3 h1:TVIw52FxVVwehat/m23+hjoFXbIvyKBA9XCVI21p68A= github.com/infobloxopen/seal v0.2.3/go.mod h1:IHbkKw7rx7oJKNtyjHL+1XaGKo5NU8CjFE3ZpA5mrB8= github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= diff --git a/grpc_opa/authorizer.go b/grpc_opa/authorizer.go index 77ddc3b..3a99943 100644 --- a/grpc_opa/authorizer.go +++ b/grpc_opa/authorizer.go @@ -120,10 +120,13 @@ func (a AuthorizeFn) Evaluate(ctx context.Context, fullMethod string, grpcReq in func NewDefaultAuthorizer(application string, opts ...Option) *DefaultAuthorizer { cfg := &Config{ - address: opa_client.DefaultAddress, - decisionInputHandler: defDecisionInputer, - claimsVerifier: UnverifiedClaimFromBearers, - acctEntitlementsApi: DefaultAcctEntitlementsApiPath, + address: opa_client.DefaultAddress, + decisionInputHandler: defDecisionInputer, + claimsVerifier: UnverifiedClaimFromBearers, + acctEntitlementsApi: DefaultAcctEntitlementsApiPath, + currUserCompartmentsApi: DefaultCurrentUserCompartmentsPath, + filterCompartmentPermsApi: DefaultFilterCompartmentPermissionsApiPath, + filterCompartmentFeatsApi: DefaultFilterCompartmentFeaturesApiPath, } for _, opt := range opts { opt(cfg) @@ -137,25 +140,31 @@ func NewDefaultAuthorizer(application string, opts ...Option) *DefaultAuthorizer } a := DefaultAuthorizer{ - clienter: clienter, - opaEvaluator: cfg.opaEvaluator, - application: application, - decisionInputHandler: cfg.decisionInputHandler, - claimsVerifier: cfg.claimsVerifier, - entitledServices: cfg.entitledServices, - acctEntitlementsApi: cfg.acctEntitlementsApi, + clienter: clienter, + opaEvaluator: cfg.opaEvaluator, + application: application, + decisionInputHandler: cfg.decisionInputHandler, + claimsVerifier: cfg.claimsVerifier, + entitledServices: cfg.entitledServices, + acctEntitlementsApi: cfg.acctEntitlementsApi, + currUserCompartmentsApi: cfg.currUserCompartmentsApi, + filterCompartmentPermsApi: cfg.filterCompartmentPermsApi, + filterCompartmentFeatsApi: cfg.filterCompartmentFeatsApi, } return &a } type DefaultAuthorizer struct { - application string - clienter opa_client.Clienter - opaEvaluator OpaEvaluator - decisionInputHandler DecisionInputHandler - claimsVerifier ClaimsVerifier - entitledServices []string - acctEntitlementsApi string + application string + clienter opa_client.Clienter + opaEvaluator OpaEvaluator + decisionInputHandler DecisionInputHandler + claimsVerifier ClaimsVerifier + entitledServices []string + acctEntitlementsApi string + currUserCompartmentsApi string + filterCompartmentPermsApi string + filterCompartmentFeatsApi string } type Config struct { @@ -163,13 +172,16 @@ type Config struct { // address to opa address string - clienter opa_client.Clienter - opaEvaluator OpaEvaluator - authorizer []Authorizer - decisionInputHandler DecisionInputHandler - claimsVerifier ClaimsVerifier - entitledServices []string - acctEntitlementsApi string + clienter opa_client.Clienter + opaEvaluator OpaEvaluator + authorizer []Authorizer + decisionInputHandler DecisionInputHandler + claimsVerifier ClaimsVerifier + entitledServices []string + acctEntitlementsApi string + currUserCompartmentsApi string + filterCompartmentPermsApi string + filterCompartmentFeatsApi string } type ClaimsVerifier func([]string, []string) (string, []error) @@ -438,6 +450,7 @@ func (o OPAResponse) Obligations() (*ObligationsNode, error) { func redactJWT(jwt string) string { parts := strings.Split(jwt, ".") + // Redact signature if len(parts) > 0 { parts[len(parts)-1] = REDACTED } @@ -446,7 +459,7 @@ func redactJWT(jwt string) string { func redactJWTForDebug(jwt string) string { parts := strings.Split(jwt, ".") - // Redact signature, header and body since we do not want to display any for debug logging + // Redact header/payload/signature, since we do not want to display any for debug logging for i := range parts { parts[i] = parts[i][:min(len(parts[i]), 16)] + "/" + REDACTED } diff --git a/grpc_opa/compartments.go b/grpc_opa/compartments.go new file mode 100644 index 0000000..1fbff35 --- /dev/null +++ b/grpc_opa/compartments.go @@ -0,0 +1,61 @@ +package grpc_opa_middleware + +import ( + "context" + "fmt" + + "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" + atlas_claims "github.com/infobloxopen/atlas-claims" + logrus "github.com/sirupsen/logrus" +) + +const ( + // DefaultCurrentUserCompartmentsPath is default OPA path to fetch current user's compartments + DefaultCurrentUserCompartmentsPath = "v1/data/authz/rbac/current_user_compartments" +) + +// CurrentUserCompartmentsResult is the data type json.Unmarshaled from OPA RESTAPI query +// to current_user_compartments rego rule +type CurrentUserCompartmentsResult struct { + Result []string `json:"result"` +} + +// GetCurrentUserCompartments returns list of compartment-ids +// for the current-user's JWT in the context. +func (a *DefaultAuthorizer) GetCurrentUserCompartments(ctx context.Context) ([]string, error) { + lgNtry := ctxlogrus.Extract(ctx) + cptResult := CurrentUserCompartmentsResult{} + + // This fetches auth data from auth headers in metadata from context: + // bearer = data from "authorization bearer" metadata header + // newBearer = data from "set-authorization bearer" metadata header + bearer, newBearer := atlas_claims.AuthBearersFromCtx(ctx) + + claimsVerifier := a.claimsVerifier + if claimsVerifier == nil { + claimsVerifier = UnverifiedClaimFromBearers + } + + rawJWT, errs := claimsVerifier([]string{bearer}, []string{newBearer}) + if len(errs) > 0 { + return nil, fmt.Errorf("%q", errs) + } + + opaReq := OPARequest{ + Input: &Payload{ + JWT: redactJWT(rawJWT), + }, + } + + err := a.clienter.CustomQuery(ctx, a.currUserCompartmentsApi, opaReq, &cptResult) + if err != nil { + lgNtry.WithError(err).Error("get_curr_user_compartments_fail") + return nil, err + } + + lgNtry.WithFields(logrus.Fields{ + "cptResult": fmt.Sprintf("%#v", cptResult), + }).Trace("get_curr_user_compartments_okay") + + return cptResult.Result, nil +} diff --git a/grpc_opa/compartments_test.go b/grpc_opa/compartments_test.go new file mode 100644 index 0000000..5f12de9 --- /dev/null +++ b/grpc_opa/compartments_test.go @@ -0,0 +1,193 @@ +package grpc_opa_middleware + +import ( + "context" + "io/ioutil" + "reflect" + "sort" + "testing" + "time" + + "github.com/infobloxopen/atlas-authz-middleware/pkg/opa_client" + "github.com/infobloxopen/atlas-authz-middleware/utils_test" + atlas_claims "github.com/infobloxopen/atlas-claims" + + "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" + logrus "github.com/sirupsen/logrus" +) + +func TestGetCurrentUserCompartmentsOpa(t *testing.T) { + stdLoggr := logrus.StandardLogger() + ctx, cancel := context.WithCancel(context.Background()) + ctx = context.WithValue(ctx, utils_test.TestingTContextKey, t) + ctx = ctxlogrus.ToContext(ctx, logrus.NewEntry(stdLoggr)) + + done := make(chan struct{}) + clienter := utils_test.StartOpa(ctx, t, done) + cli, ok := clienter.(*opa_client.Client) + if !ok { + t.Fatal("Unable to convert interface to (*Client)") + return + } + + // Errors above here will leak containers + defer func() { + cancel() + // Wait for container to be shutdown + <-done + }() + + policyRego, err := ioutil.ReadFile("testdata/mock_authz_policy.rego") + if err != nil { + t.Fatalf("ReadFile fatal err: %#v", err) + return + } + + var resp interface{} + err = cli.UploadRegoPolicy(ctx, "mock_authz_policyid", policyRego, resp) + if err != nil { + t.Fatalf("OpaUploadPolicy fatal err: %#v", err) + return + } + + auther := NewDefaultAuthorizer("bogus_unused_application_value", + WithOpaClienter(cli), + ) + + testCases := []struct { + name string + acctId string + groups []string + expVal []string + }{ + { + name: "40; custom-admin-group,user-group-40;", + acctId: "40", + groups: []string{"custom-admin-group", "user-group-40"}, + expVal: []string{"compartment-40-red."}, + }, + { + name: "40; custom-admin-group,user;", + acctId: "40", + groups: []string{"custom-admin-group", "user"}, + expVal: []string{"compartment-40-red.", "compartment-40-green."}, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + claims := &atlas_claims.Claims{ + AccountId: tt.acctId, + Groups: tt.groups, + } + + jwt, err := atlas_claims.BuildJwt(claims, "some-hmac-key-we-dont-care", time.Hour*9) + if err != nil { + t.Fatalf("FAIL: BuildJwt() unexpected err=%v", err) + } + + ttCtx := utils_test.ContextWithJWT(ctx, jwt) + + gotVal, err := auther.GetCurrentUserCompartments(ttCtx) + if err != nil { + t.Errorf("FAIL: GetCurrentUserCompartments() unexpected err=%v", err) + } + + sort.Strings(gotVal) + //t.Logf("gotVal=%#v", gotVal) + + sort.Strings(tt.expVal) + if !reflect.DeepEqual(gotVal, tt.expVal) { + t.Errorf("FAIL:\ngotVal: %#v\nexpVal: %#v", + gotVal, tt.expVal) + } + }) + } +} + +func TestGetCurrentUserCompartmentsMockOpaClient(t *testing.T) { + testCases := []struct { + name string + respJson string + expErr bool + expVal []string + }{ + { + name: `valid result`, + respJson: `{ "result": [ "red.", "green.", "blue." ] }`, + expErr: false, + expVal: []string{"red.", "green.", "blue."}, + }, + { + name: `null result ok`, + respJson: `{ "result": null }`, + expErr: false, + expVal: nil, + }, + { + name: `empty result ok`, + respJson: `{ "result": [] }`, + expErr: false, + expVal: []string{}, + }, + { + name: `incorrect result type`, + respJson: `[ null ]`, + expErr: true, + expVal: nil, + }, + { + name: `no result key`, + respJson: `{ "rresult": null }`, + expErr: false, + expVal: nil, + }, + { + name: `invalid result object`, + respJson: `{ "result": { "one": 1, "two": 2 } }`, + expErr: true, + expVal: nil, + }, + } + + stdLoggr := logrus.StandardLogger() + ctx := context.WithValue(context.Background(), utils_test.TestingTContextKey, t) + ctx = ctxlogrus.ToContext(ctx, logrus.NewEntry(stdLoggr)) + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + mockOpaClienter := utils_test.MockOpaClienter{ + Loggr: stdLoggr, + RegoRespJSON: tt.respJson, + } + auther := NewDefaultAuthorizer("bogus_unused_application_value", + WithOpaClienter(&mockOpaClienter), + ) + + claims := &atlas_claims.Claims{} + jwt, err := atlas_claims.BuildJwt(claims, "some-hmac-key-we-dont-care", time.Hour*9) + if err != nil { + t.Fatalf("FAIL: BuildJwt() unexpected err=%v", err) + } + ttCtx := utils_test.ContextWithJWT(ctx, jwt) + + gotVal, gotErr := auther.GetCurrentUserCompartments(ttCtx) + //t.Logf("gotErr=%#v, gotVal=%#v", gotVal, gotErr) + + if tt.expErr && gotErr == nil { + t.Errorf("FAIL: expected err, but got no err") + } else if !tt.expErr && gotErr != nil { + t.Errorf("FAIL: got unexpected err=%s", gotErr) + } + + if gotErr != nil && gotVal != nil { + t.Errorf("FAIL: returned val should be nil if err returned") + } + + if !reflect.DeepEqual(gotVal, tt.expVal) { + t.Errorf("FAIL: expVal=%#v gotVal=%#v", + tt.expVal, gotVal) + } + }) + } +} diff --git a/grpc_opa/filter_compartment_perms_feats.go b/grpc_opa/filter_compartment_perms_feats.go new file mode 100644 index 0000000..19e3adc --- /dev/null +++ b/grpc_opa/filter_compartment_perms_feats.go @@ -0,0 +1,130 @@ +package grpc_opa_middleware + +import ( + "context" + "fmt" + + "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" + atlas_claims "github.com/infobloxopen/atlas-claims" + logrus "github.com/sirupsen/logrus" +) + +const ( + // DefaultFilterCompartmentPermissionsApiPath is default OPA path to filter compartment permissions + DefaultFilterCompartmentPermissionsApiPath = "v1/data/authz/rbac/filter_compartment_permissions_api" + + // DefaultFilterCompartmentFeaturesApiPath is default OPA path to filter compartment features + DefaultFilterCompartmentFeaturesApiPath = "v1/data/authz/rbac/filter_compartment_features_api" +) + +// FilterCompartmentPermissionsType is a convenience data type, returned by FilterCompartmentPermissions() +// (map of application to array of permissions) +type FilterCompartmentPermissionsType []string + +// FilterCompartmentFeaturesType is a convenience data type, returned by FilterCompartmentFeatures() +// (map of application to array of feature) +type FilterCompartmentFeaturesType map[string][]string + +// FilterCompartmentPermissionsInput is the input payload for filter_compartment_permissions_api +type FilterCompartmentPermissionsInput struct { + JWT string `json:"jwt"` + Permissions FilterCompartmentPermissionsType `json:"permissions"` +} + +// FilterCompartmentPermissionsResult is the data type json.Unmarshaled from OPA RESTAPI query +// to filter_compartment_permissions_api rego rule +type FilterCompartmentPermissionsResult struct { + Result FilterCompartmentPermissionsType `json:"result"` +} + +// FilterCompartmentPermissions filters list of permissions based on the JWT in the context +func (a *DefaultAuthorizer) FilterCompartmentPermissions(ctx context.Context, permissions FilterCompartmentPermissionsType) (FilterCompartmentPermissionsType, error) { + lgNtry := ctxlogrus.Extract(ctx) + permsResult := FilterCompartmentPermissionsResult{} + + // This fetches auth data from auth headers in metadata from context: + // bearer = data from "authorization bearer" metadata header + // newBearer = data from "set-authorization bearer" metadata header + bearer, newBearer := atlas_claims.AuthBearersFromCtx(ctx) + + claimsVerifier := a.claimsVerifier + if claimsVerifier == nil { + claimsVerifier = UnverifiedClaimFromBearers + } + + rawJWT, errs := claimsVerifier([]string{bearer}, []string{newBearer}) + if len(errs) > 0 { + return nil, fmt.Errorf("%q", errs) + } + + opaReq := OPARequest{ + Input: &FilterCompartmentPermissionsInput{ + JWT: redactJWT(rawJWT), + Permissions: permissions, + }, + } + + err := a.clienter.CustomQuery(ctx, a.filterCompartmentPermsApi, opaReq, &permsResult) + if err != nil { + lgNtry.WithError(err).Error("filter_compartment_permissions_fail") + return nil, err + } + + lgNtry.WithFields(logrus.Fields{ + "permsResult": fmt.Sprintf("%#v", permsResult), + }).Trace("filter_compartment_permissions_okay") + + return permsResult.Result, nil +} + +// FilterCompartmentFeaturesInput is the input payload for filter_compartment_features_api +type FilterCompartmentFeaturesInput struct { + JWT string `json:"jwt"` + ApplicationFeatures FilterCompartmentFeaturesType `json:"application_features"` +} + +// FilterCompartmentFeaturesResult is the data type json.Unmarshaled from OPA RESTAPI query +// to filter_compartment_features_api rego rule +type FilterCompartmentFeaturesResult struct { + Result FilterCompartmentFeaturesType `json:"result"` +} + +// FilterCompartmentFeatures filters list of features based on the JWT in the context +func (a *DefaultAuthorizer) FilterCompartmentFeatures(ctx context.Context, features FilterCompartmentFeaturesType) (FilterCompartmentFeaturesType, error) { + lgNtry := ctxlogrus.Extract(ctx) + featsResult := FilterCompartmentFeaturesResult{} + + // This fetches auth data from auth headers in metadata from context: + // bearer = data from "authorization bearer" metadata header + // newBearer = data from "set-authorization bearer" metadata header + bearer, newBearer := atlas_claims.AuthBearersFromCtx(ctx) + + claimsVerifier := a.claimsVerifier + if claimsVerifier == nil { + claimsVerifier = UnverifiedClaimFromBearers + } + + rawJWT, errs := claimsVerifier([]string{bearer}, []string{newBearer}) + if len(errs) > 0 { + return nil, fmt.Errorf("%q", errs) + } + + opaReq := OPARequest{ + Input: &FilterCompartmentFeaturesInput{ + JWT: redactJWT(rawJWT), + ApplicationFeatures: features, + }, + } + + err := a.clienter.CustomQuery(ctx, a.filterCompartmentFeatsApi, opaReq, &featsResult) + if err != nil { + lgNtry.WithError(err).Error("filter_compartment_features_fail") + return nil, err + } + + lgNtry.WithFields(logrus.Fields{ + "featsResult": fmt.Sprintf("%#v", featsResult), + }).Trace("filter_compartment_features_okay") + + return featsResult.Result, nil +} diff --git a/grpc_opa/filter_compartment_perms_feats_test.go b/grpc_opa/filter_compartment_perms_feats_test.go new file mode 100644 index 0000000..0e3f819 --- /dev/null +++ b/grpc_opa/filter_compartment_perms_feats_test.go @@ -0,0 +1,394 @@ +package grpc_opa_middleware + +import ( + "context" + "io/ioutil" + "reflect" + "sort" + "testing" + "time" + + "github.com/infobloxopen/atlas-authz-middleware/pkg/opa_client" + "github.com/infobloxopen/atlas-authz-middleware/utils_test" + atlas_claims "github.com/infobloxopen/atlas-claims" + + "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" + logrus "github.com/sirupsen/logrus" +) + +func TestFilterCompartmentPermissionsOpa(t *testing.T) { + stdLoggr := logrus.StandardLogger() + ctx, cancel := context.WithCancel(context.Background()) + ctx = context.WithValue(ctx, utils_test.TestingTContextKey, t) + ctx = ctxlogrus.ToContext(ctx, logrus.NewEntry(stdLoggr)) + + done := make(chan struct{}) + clienter := utils_test.StartOpa(ctx, t, done) + cli, ok := clienter.(*opa_client.Client) + if !ok { + t.Fatal("Unable to convert interface to (*Client)") + return + } + + // Errors above here will leak containers + defer func() { + cancel() + // Wait for container to be shutdown + <-done + }() + + policyRego, err := ioutil.ReadFile("testdata/mock_authz_policy.rego") + if err != nil { + t.Fatalf("ReadFile fatal err: %#v", err) + return + } + + var resp interface{} + err = cli.UploadRegoPolicy(ctx, "mock_authz_policyid", policyRego, resp) + if err != nil { + t.Fatalf("OpaUploadPolicy fatal err: %#v", err) + return + } + + auther := NewDefaultAuthorizer("bogus_unused_application_value", + WithOpaClienter(cli), + ) + + testCases := []struct { + name string + cptId string + inpVal []string + expVal []string + }{ + { + name: "empty-compartment", + cptId: "", + inpVal: []string{"user-view", "tag-read"}, + expVal: []string{"user-view", "tag-read"}, + }, + { + name: "root-compartment", + cptId: ".", + inpVal: []string{"user-view", "tag-read"}, + expVal: []string{"user-view", "tag-read"}, + }, + { + name: "non-root-compartment", + cptId: "green.", + inpVal: []string{"user-view", "tag-read"}, + expVal: []string{"filtered-perm-a", "filtered-perm-b"}, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + claims := &atlas_claims.Claims{ + CompartmentID: tt.cptId, + } + + jwt, err := atlas_claims.BuildJwt(claims, "some-hmac-key-we-dont-care", time.Hour*9) + if err != nil { + t.Fatalf("FAIL: BuildJwt() unexpected err=%v", err) + } + + ttCtx := utils_test.ContextWithJWT(ctx, jwt) + + gotVal, err := auther.FilterCompartmentPermissions(ttCtx, tt.inpVal) + if err != nil { + t.Errorf("FAIL: FilterCompartmentPermissions() unexpected err=%v", err) + } + + sort.Strings(gotVal) + //t.Logf("gotVal=%#v", gotVal) + + sort.Strings(tt.expVal) + if !reflect.DeepEqual([]string(gotVal), tt.expVal) { + t.Errorf("FAIL:\ngotVal: %#v\nexpVal: %#v", + gotVal, tt.expVal) + } + }) + } +} + +func TestFilterCompartmentPermissionsMockOpaClient(t *testing.T) { + testCases := []struct { + name string + respJson string + expErr bool + expVal []string + }{ + { + name: `valid result`, + respJson: `{ "result": [ "red.", "green.", "blue." ] }`, + expErr: false, + expVal: []string{"red.", "green.", "blue."}, + }, + { + name: `null result ok`, + respJson: `{ "result": null }`, + expErr: false, + expVal: nil, + }, + { + name: `empty result ok`, + respJson: `{ "result": [] }`, + expErr: false, + expVal: []string{}, + }, + { + name: `incorrect result type`, + respJson: `[ null ]`, + expErr: true, + expVal: nil, + }, + { + name: `no result key`, + respJson: `{ "rresult": null }`, + expErr: false, + expVal: nil, + }, + { + name: `invalid result object`, + respJson: `{ "result": { "one": 1, "two": 2 } }`, + expErr: true, + expVal: nil, + }, + } + + stdLoggr := logrus.StandardLogger() + ctx := context.WithValue(context.Background(), utils_test.TestingTContextKey, t) + ctx = ctxlogrus.ToContext(ctx, logrus.NewEntry(stdLoggr)) + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + mockOpaClienter := utils_test.MockOpaClienter{ + Loggr: stdLoggr, + RegoRespJSON: tt.respJson, + } + auther := NewDefaultAuthorizer("bogus_unused_application_value", + WithOpaClienter(&mockOpaClienter), + ) + + claims := &atlas_claims.Claims{} + jwt, err := atlas_claims.BuildJwt(claims, "some-hmac-key-we-dont-care", time.Hour*9) + if err != nil { + t.Fatalf("FAIL: BuildJwt() unexpected err=%v", err) + } + ttCtx := utils_test.ContextWithJWT(ctx, jwt) + + gotVal, gotErr := auther.FilterCompartmentPermissions(ttCtx, nil) + //t.Logf("gotErr=%#v, gotVal=%#v", gotVal, gotErr) + + if tt.expErr && gotErr == nil { + t.Errorf("FAIL: expected err, but got no err") + } else if !tt.expErr && gotErr != nil { + t.Errorf("FAIL: got unexpected err=%s", gotErr) + } + + if gotErr != nil && gotVal != nil { + t.Errorf("FAIL: returned val should be nil if err returned") + } + + if !reflect.DeepEqual([]string(gotVal), tt.expVal) { + t.Errorf("FAIL: expVal=%#v gotVal=%#v", + tt.expVal, gotVal) + } + }) + } +} + +func TestFilterCompartmentFeaturesOpa(t *testing.T) { + stdLoggr := logrus.StandardLogger() + ctx, cancel := context.WithCancel(context.Background()) + ctx = context.WithValue(ctx, utils_test.TestingTContextKey, t) + ctx = ctxlogrus.ToContext(ctx, logrus.NewEntry(stdLoggr)) + + done := make(chan struct{}) + clienter := utils_test.StartOpa(ctx, t, done) + cli, ok := clienter.(*opa_client.Client) + if !ok { + t.Fatal("Unable to convert interface to (*Client)") + return + } + + // Errors above here will leak containers + defer func() { + cancel() + // Wait for container to be shutdown + <-done + }() + + policyRego, err := ioutil.ReadFile("testdata/mock_authz_policy.rego") + if err != nil { + t.Fatalf("ReadFile fatal err: %#v", err) + return + } + + var resp interface{} + err = cli.UploadRegoPolicy(ctx, "mock_authz_policyid", policyRego, resp) + if err != nil { + t.Fatalf("OpaUploadPolicy fatal err: %#v", err) + return + } + + auther := NewDefaultAuthorizer("bogus_unused_application_value", + WithOpaClienter(cli), + ) + + testCases := []struct { + name string + cptId string + inpVal map[string][]string + expVal map[string][]string + }{ + { + name: "empty-compartment", + cptId: "", + inpVal: map[string][]string{"ddi": {"dhcp", "ipam"}, "ui": {"anycast"}}, + expVal: map[string][]string{"ddi": {"dhcp", "ipam"}, "ui": {"anycast"}}, + }, + { + name: "root-compartment", + cptId: ".", + inpVal: map[string][]string{"ddi": {"dhcp", "ipam"}, "ui": {"anycast"}}, + expVal: map[string][]string{"ddi": {"dhcp", "ipam"}, "ui": {"anycast"}}, + }, + { + name: "non-root-compartment", + cptId: "green.", + inpVal: map[string][]string{"ddi": {"dhcp", "ipam"}, "ui": {"anycast"}}, + expVal: map[string][]string{ + "filtered-app-a": {"filtered-app-a-feat-a", "filtered-app-a-feat-b"}, + "filtered-app-b": {"filtered-app-b-feat-a"}, + }, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + claims := &atlas_claims.Claims{ + CompartmentID: tt.cptId, + } + + jwt, err := atlas_claims.BuildJwt(claims, "some-hmac-key-we-dont-care", time.Hour*9) + if err != nil { + t.Fatalf("FAIL: BuildJwt() unexpected err=%v", err) + } + + ttCtx := utils_test.ContextWithJWT(ctx, jwt) + + gotVal, err := auther.FilterCompartmentFeatures(ttCtx, tt.inpVal) + if err != nil { + t.Errorf("FAIL: FilterCompartmentFeatures() unexpected err=%v", err) + } + + //sort.Strings(gotVal) + //t.Logf("gotVal=%#v", gotVal) + + //sort.Strings(tt.expVal) + if !reflect.DeepEqual(map[string][]string(gotVal), tt.expVal) { + t.Errorf("FAIL:\ngotVal: %#v\nexpVal: %#v", + gotVal, tt.expVal) + } + }) + } +} + +func TestFilterCompartmentFeaturesMockOpaClient(t *testing.T) { + testCases := []struct { + name string + respJson string + expErr bool + expVal map[string][]string + }{ + { + name: `valid result`, + respJson: `{ "result": { "app": [ "feat-a", "feat-b" ] } }`, + expErr: false, + expVal: map[string][]string{"app": {"feat-a", "feat-b"}}, + }, + { + name: `null result ok`, + respJson: `{ "result": null }`, + expErr: false, + expVal: nil, + }, + { + name: `empty result ok`, + respJson: `{ "result": {} }`, + expErr: false, + expVal: map[string][]string{}, + }, + { + name: `null features ok`, + respJson: `{ "result": { "app": null } }`, + expErr: false, + expVal: map[string][]string{"app": nil}, + }, + { + name: `empty features ok`, + respJson: `{ "result": { "app": [] } }`, + expErr: false, + expVal: map[string][]string{"app": {}}, + }, + { + name: `incorrect result type`, + respJson: `[ null ]`, + expErr: true, + expVal: nil, + }, + { + name: `no result key`, + respJson: `{ "rresult": null }`, + expErr: false, + expVal: nil, + }, + { + name: `invalid result object`, + respJson: `{ "result": [ "one", "two" ] }`, + expErr: true, + expVal: nil, + }, + } + + stdLoggr := logrus.StandardLogger() + ctx := context.WithValue(context.Background(), utils_test.TestingTContextKey, t) + ctx = ctxlogrus.ToContext(ctx, logrus.NewEntry(stdLoggr)) + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + mockOpaClienter := utils_test.MockOpaClienter{ + Loggr: stdLoggr, + RegoRespJSON: tt.respJson, + } + auther := NewDefaultAuthorizer("bogus_unused_application_value", + WithOpaClienter(&mockOpaClienter), + ) + + claims := &atlas_claims.Claims{} + jwt, err := atlas_claims.BuildJwt(claims, "some-hmac-key-we-dont-care", time.Hour*9) + if err != nil { + t.Fatalf("FAIL: BuildJwt() unexpected err=%v", err) + } + ttCtx := utils_test.ContextWithJWT(ctx, jwt) + + gotVal, gotErr := auther.FilterCompartmentFeatures(ttCtx, nil) + //t.Logf("gotErr=%#v, gotVal=%#v", gotVal, gotErr) + + if tt.expErr && gotErr == nil { + t.Errorf("FAIL: expected err, but got no err") + } else if !tt.expErr && gotErr != nil { + t.Errorf("FAIL: got unexpected err=%s", gotErr) + } + + if gotErr != nil && gotVal != nil { + t.Errorf("FAIL: returned val should be nil if err returned") + } + + if !reflect.DeepEqual(map[string][]string(gotVal), tt.expVal) { + t.Errorf("FAIL: expVal=%#v gotVal=%#v", + tt.expVal, gotVal) + } + }) + } +} diff --git a/grpc_opa/options.go b/grpc_opa/options.go index 562e023..92649e2 100644 --- a/grpc_opa/options.go +++ b/grpc_opa/options.go @@ -79,3 +79,17 @@ func WithAcctEntitlementsApiPath(acctEntitlementsApi string) Option { c.acctEntitlementsApi = acctEntitlementsApi } } + +// WithFilterComparmentPermissionsApiPath overrides default CurrentUserCompartmentsApiPath +func WithFilterComparmentPermissionsApiPath(filterCompartmentPermsApi string) Option { + return func(c *Config) { + c.filterCompartmentPermsApi = filterCompartmentPermsApi + } +} + +// WithFilterComparmentFeaturesApiPath overrides default CurrentUserCompartmentsApiPath +func WithFilterComparmentFeaturesApiPath(filterCompartmentFeatsApi string) Option { + return func(c *Config) { + c.filterCompartmentFeatsApi = filterCompartmentFeatsApi + } +} diff --git a/grpc_opa/testdata/mock_authz_policy.rego b/grpc_opa/testdata/mock_authz_policy.rego index ead8d65..8a7cfeb 100644 --- a/grpc_opa/testdata/mock_authz_policy.rego +++ b/grpc_opa/testdata/mock_authz_policy.rego @@ -1,5 +1,19 @@ package authz.rbac +has_token { + is_string(input.jwt) + count(trim_space(input.jwt)) > 0 +} + +merged_input = payload { + has_token + [_, payload, _] := io.jwt.decode(input.jwt) +} + +else = payload { + payload := input +} + validate_v1 = { "allow": true, } @@ -118,6 +132,202 @@ test_acct_entitlements_api_with_input { } } +group_compartment_roles := { + "40": { + "all-resources": { + "custom-admin-group": { + ".": [ + "custom-admin-role", + "administrator-role" + ], + "compartment-40-red.": [ + "devops-role", + "secops-role" + ] + }, + "user-group-40": { + ".": [ + "custom-admin-role", + "administrator-role" + ], + "compartment-40-red.": [ + "devops-role", + "secops-role" + ] + }, + "user": { + "compartment-40-green.": [ + "readonly-role" + ] + } + } + }, + "16": { + "all-resources": { + "custom-admin-group-16": { + ".": [ + "custom-admin-role", + "administrator-role" + ], + "compartment-16-red.": [ + "devops-role", + "secops-role" + ] + }, + "user-group-16": { + ".": [ + "custom-admin-role", + "administrator-role" + ], + "compartment-16-red.": [ + "devops-role", + "secops-role" + ] + }, + "user": { + "compartment-16-green.": [ + "readonly-role" + ] + } + } + }, + "3101": { + "all-resources": { + "devops-group": { + ".": [ + "widget-role-read" + ], + "red.": [ + "widget-role-create", + "gadget-role-create" + ], + "green.": [ + "widget-role-update" + ], + "green.car.": [ + "gizmo-role-create" + ] + }, + "secops-group": { + ".": [ + "gadget-role-read", + "gadget-role-list" + ], + "green.": [ + "gadget-role-update" + ], + "green.car.": [ + "gizmo-role-update" + ], + "green.boat.": [ + "gizmo-role-delete" + ], + "green.car.wheel.": [ + "gizmo-role-read" + ], + "green.boat.anchor.": [ + "gizmo-role-list" + ], + "blue.": [ + "widget-role-delete", + "gadget-role-delete" + ] + } + } + } +} + +# Well-known hardcoded root-compartment id used throughout AuthZ/Identity code +ROOT_COMPARTMENT_ID := "." + +current_user_compartments[compartment] { + compartment != ROOT_COMPARTMENT_ID + group_compartment_roles[merged_input.account_id][_][merged_input.groups[_]][compartment] +} + +current_user_compartments_test_fn(acct_id, groups, exp_set) { + got_set := current_user_compartments with input as { + "account_id": acct_id, + "groups": groups, + } + trace(sprintf("got_set: %v", [got_set])) + trace(sprintf("exp_set: %v", [exp_set])) + got_set == exp_set +} + +test_current_user_compartments { + current_user_compartments_test_fn("40", ["custom-admin-group", "user-group-40"], + {"compartment-40-red."}) + current_user_compartments_test_fn("40", ["custom-admin-group", "user"], + {"compartment-40-red.", "compartment-40-green."}) +} + +filter_compartment_permissions_api = filtered_perm_arr { + count(trim_space(merged_input.compartment_id)) > 0 + merged_input.compartment_id != ROOT_COMPARTMENT_ID + filtered_perm_arr := ["filtered-perm-a", "filtered-perm-b"] +} else = filtered_perm_arr { + filtered_perm_arr := input.permissions +} + +filter_compartment_features_api = filtered_feat_map { + count(trim_space(merged_input.compartment_id)) > 0 + merged_input.compartment_id != ROOT_COMPARTMENT_ID + filtered_feat_map := { + "filtered-app-a": ["filtered-app-a-feat-a", "filtered-app-a-feat-b"], + "filtered-app-b": ["filtered-app-b-feat-a"], + } +} else = filtered_feat_map { + filtered_feat_map := input.application_features +} + +filter_compartment_permissions_test_fn(cpt_id, perm_arr, exp_set) { + got_set := filter_compartment_permissions_api with input as { + "compartment_id": cpt_id, + "permissions": perm_arr, + } + trace(sprintf("got_set: %v", [got_set])) + trace(sprintf("exp_set: %v", [exp_set])) + got_set == exp_set +} + +filter_compartment_features_test_fn(cpt_id, app_feat_map, exp_map) { + got_map := filter_compartment_features_api with input as { + "compartment_id": cpt_id, + "application_features": app_feat_map, + } + trace(sprintf("got_map: %v", [got_map])) + trace(sprintf("exp_map: %v", [exp_map])) + got_map == exp_map +} + +test_filter_compartment_api { + filter_compartment_permissions_test_fn("", + ["user-view", "tag-read"], + ["user-view", "tag-read"]) + + filter_compartment_permissions_test_fn(".", + ["user-view", "tag-read"], + ["user-view", "tag-read"]) + + filter_compartment_permissions_test_fn("green.", + ["user-view", "tag-read"], + ["filtered-perm-a", "filtered-perm-b"]) + + filter_compartment_features_test_fn("", + {"ddi": ["dhcp", "ipam"], "ui": ["anycast"]}, + {"ddi": ["dhcp", "ipam"], "ui": ["anycast"]}) + + filter_compartment_features_test_fn(".", + {"ddi": ["dhcp", "ipam"], "ui": ["anycast"]}, + {"ddi": ["dhcp", "ipam"], "ui": ["anycast"]}) + + filter_compartment_features_test_fn("green.", + {"ddi": ["dhcp", "ipam"], "ui": ["anycast"]}, + {"filtered-app-a": ["filtered-app-a-feat-a", "filtered-app-a-feat-b"], + "filtered-app-b": ["filtered-app-b-feat-a"]}) +} + # opa test -v mock_authz_policy.rego # opa run --server mock_authz_policy.rego # curl -X GET -H 'Content-Type: application/json' http://localhost:8181/v1/data/authz/rbac/acct_entitlements_api | jq . diff --git a/utils_test/jwt.go b/utils_test/jwt.go new file mode 100644 index 0000000..52c75c2 --- /dev/null +++ b/utils_test/jwt.go @@ -0,0 +1,18 @@ +package utils_test + +import ( + "context" + "fmt" + + "github.com/grpc-ecosystem/go-grpc-middleware/util/metautils" + "google.golang.org/grpc/metadata" +) + +// ContextWithJWT adds JWT as authorization-bearer token to context, returning the new context. +// From https://github.com/grpc-ecosystem/go-grpc-middleware/blob/master/auth/metadata_test.go +func ContextWithJWT(ctx context.Context, jwtStr string) context.Context { + bearerStr := fmt.Sprintf(`bearer %s`, jwtStr) + md := metadata.Pairs(`authorization`, bearerStr) + ctx = metautils.NiceMD(md).ToIncoming(ctx) + return ctx +} diff --git a/utils_test/mock_opa_client.go b/utils_test/mock_opa_client.go new file mode 100644 index 0000000..a2b0d75 --- /dev/null +++ b/utils_test/mock_opa_client.go @@ -0,0 +1,46 @@ +package utils_test + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/infobloxopen/atlas-authz-middleware/pkg/opa_client" + "github.com/sirupsen/logrus" +) + +// MockOpaClienter mocks the opa_client.Clienter interface +type MockOpaClienter struct { + Loggr *logrus.Logger + RegoRespJSON string +} + +func (m MockOpaClienter) String() string { + return fmt.Sprintf(`MockOpaClienter{RegoRespJSON:"%s"}`, m.RegoRespJSON) +} + +func (m MockOpaClienter) Address() string { + return "http://localhost:8181" +} + +func (m MockOpaClienter) Health() error { + return nil +} + +func (m MockOpaClienter) Query(ctx context.Context, reqData, resp interface{}) error { + return m.CustomQuery(ctx, "", reqData, resp) +} + +func (m MockOpaClienter) CustomQueryStream(ctx context.Context, document string, postReqBody []byte, respRdrFn opa_client.StreamReaderFn) error { + return nil +} + +func (m MockOpaClienter) CustomQueryBytes(ctx context.Context, document string, reqData interface{}) ([]byte, error) { + return []byte(m.RegoRespJSON), nil +} + +func (m MockOpaClienter) CustomQuery(ctx context.Context, document string, reqData, resp interface{}) error { + err := json.Unmarshal([]byte(m.RegoRespJSON), resp) + m.Loggr.Debugf("CustomQuery: resp=%#v", resp) + return err +}