diff --git a/common/authorizer/authorizer.go b/common/authorizer/authorizer.go index cf94c78..f48562d 100644 --- a/common/authorizer/authorizer.go +++ b/common/authorizer/authorizer.go @@ -7,6 +7,10 @@ type OpaEvaluator func(ctx context.Context, decisionDocument string, opaReq, opa type ClaimsVerifier func([]string, []string) (string, []error) +// AcctEntitlementsType is a convenience data type, returned by GetAcctEntitlements() +// (map of acct_id to map of service to array of features) +type AcctEntitlementsType map[string]map[string][]string + // Authorizer interface is implemented for making arbitrary requests to Opa. type Authorizer interface { // Evaluate evaluates the authorization policy for the given request. @@ -23,4 +27,8 @@ type Authorizer interface { OpaQuery(ctx context.Context, decisionDocument string, opaReq, opaResp interface{}) error AffirmAuthorization(ctx context.Context, fullMethod string, eq interface{}) (context.Context, error) + + GetAcctEntitlements(ctx context.Context, accountIDs, serviceNames []string) (*AcctEntitlementsType, error) + + GetCurrentUserCompartments(ctx context.Context) ([]string, error) } diff --git a/common/authorizer/literal.go b/common/authorizer/literal.go index 502519e..0ba4bea 100644 --- a/common/authorizer/literal.go +++ b/common/authorizer/literal.go @@ -8,6 +8,12 @@ const ( // DefaultValidatePath is default OPA path to perform authz validation DefaultValidatePath = "v1/data/authz/rbac/validate_v1" + // DefaultAcctEntitlementsApiPath is default OPA path to fetch acct entitlements + DefaultAcctEntitlementsApiPath = "v1/data/authz/rbac/acct_entitlements_api" + + // DefaultCurrentUserCompartmentsPath is default OPA path to fetch current user's compartments + DefaultCurrentUserCompartmentsPath = "v1/data/authz/rbac/current_user_compartments" + REDACTED = "redacted" TypeKey = ABACKey("ABACType") VerbKey = ABACKey("ABACVerb") diff --git a/common/authorizer/mock_Authorizer.go b/common/authorizer/mock_Authorizer.go index 4a32bd0..8c78c8d 100644 --- a/common/authorizer/mock_Authorizer.go +++ b/common/authorizer/mock_Authorizer.go @@ -78,3 +78,33 @@ func (mr *MockAuthorizerMockRecorder) OpaQuery(ctx, decisionDocument, opaReq, op mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpaQuery", reflect.TypeOf((*MockAuthorizer)(nil).OpaQuery), ctx, decisionDocument, opaReq, opaResp) } + +// GetAcctEntitlements mocks base method. +func (m *MockAuthorizer) GetAcctEntitlements(ctx context.Context, accountIDs, serviceNames []string) (*AcctEntitlementsType, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAcctEntitlements", ctx) + ret0, _ := ret[0].(*AcctEntitlementsType) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAcctEntitlements indicates an expected call of GetAcctEntitlements. +func (mr *MockAuthorizerMockRecorder) GetAcctEntitlements(ctx context.Context, accountIDs, serviceNames []string) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAcctEntitlements", reflect.TypeOf((*MockAuthorizer)(nil).GetAcctEntitlements), ctx, accountIDs, serviceNames) +} + +// GetCurrentUserCompartments mocks base method. +func (m *MockAuthorizer) GetCurrentUserCompartments(ctx context.Context) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCurrentUserCompartments", ctx) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCurrentUserCompartments indicates an expected call of GetCurrentUserCompartments. +func (mr *MockAuthorizerMockRecorder) GetCurrentUserCompartments(ctx context.Context) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentUserCompartments", reflect.TypeOf((*MockAuthorizer)(nil).GetCurrentUserCompartments), ctx) +} diff --git a/common/literal.go b/common/literal.go deleted file mode 100644 index ca4d587..0000000 --- a/common/literal.go +++ /dev/null @@ -1,6 +0,0 @@ -package common - -const ( - // DefaultAcctEntitlementsApiPath is default OPA path to fetch acct entitlements - DefaultAcctEntitlementsApiPath = "v1/data/authz/rbac/acct_entitlements_api" -) diff --git a/http_opa/acct_entitlements.go b/http_opa/acct_entitlements.go new file mode 100644 index 0000000..9f57daf --- /dev/null +++ b/http_opa/acct_entitlements.go @@ -0,0 +1,57 @@ +package httpopa + +import ( + "context" + "fmt" + + "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" + az "github.com/infobloxopen/atlas-authz-middleware/v2/common/authorizer" + "github.com/infobloxopen/atlas-authz-middleware/v2/common/opautil" + logrus "github.com/sirupsen/logrus" +) + +// AcctEntitlementsApiInput is the input payload for acct_entitlements_api +type AcctEntitlementsApiInput struct { + AccountIDs []string `json:"acct_entitlements_acct_ids"` + ServiceNames []string `json:"acct_entitlements_services"` +} + +// AcctEntitlementsApiResult is the data type json.Unmarshaled from OPA RESTAPI query to acct_entitlements_api +type AcctEntitlementsApiResult struct { + Result *az.AcctEntitlementsType `json:"result"` +} + +// GetAcctEntitlements queries account entitled features data +// for the specified account-ids and entitled-services. +// If both account-ids and entitled-services are empty, +// then data for all entitled-services in all accounts are returned. +func (a *httpAuthorizer) GetAcctEntitlements(ctx context.Context, accountIDs, serviceNames []string) (*az.AcctEntitlementsType, error) { + lgNtry := ctxlogrus.Extract(ctx) + acctResult := AcctEntitlementsApiResult{} + + if accountIDs == nil { + accountIDs = []string{} + } + if serviceNames == nil { + serviceNames = []string{} + } + + opaReq := opautil.OPARequest{ + Input: &AcctEntitlementsApiInput{ + AccountIDs: accountIDs, + ServiceNames: serviceNames, + }, + } + + err := a.clienter.CustomQuery(ctx, a.acctEntitlementsApi, opaReq, &acctResult) + if err != nil { + lgNtry.WithError(err).Error("get_acct_entitlements_fail") + return nil, err + } + + lgNtry.WithFields(logrus.Fields{ + "acctResult": fmt.Sprintf("%#v", acctResult), + }).Trace("get_acct_entitlements_okay") + + return acctResult.Result, nil +} diff --git a/http_opa/acct_entitlements_test.go b/http_opa/acct_entitlements_test.go new file mode 100644 index 0000000..f94bea7 --- /dev/null +++ b/http_opa/acct_entitlements_test.go @@ -0,0 +1,197 @@ +package httpopa + +import ( + "context" + "io/ioutil" + "reflect" + "testing" + + az "github.com/infobloxopen/atlas-authz-middleware/v2/common/authorizer" + "github.com/infobloxopen/atlas-authz-middleware/v2/pkg/opa_client" + "github.com/infobloxopen/atlas-authz-middleware/v2/utils_test" + + "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" + logrus "github.com/sirupsen/logrus" +) + +func TestGetAcctEntitlementsOpa(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 := NewHttpAuthorizer("bogus_unused_application_value", + WithOpaClienter(cli), + ) + + actualSpecific, err := auther.GetAcctEntitlements(ctx, + []string{"2001040", "2001230"}, []string{"powertrain", "wheel"}) + if err != nil { + t.Errorf("FAIL: GetAcctEntitlements() unexpected err=%v", err) + } + t.Logf("actualSpecific=%#v", actualSpecific) + + expectSpecific := &az.AcctEntitlementsType{ + "2001040": { + "powertrain": {"automatic", "turbo"}, + }, + "2001230": { + "powertrain": {"manual", "v8"}, + "wheel": {"run-flat"}, + }, + } + if !reflect.DeepEqual(actualSpecific, expectSpecific) { + t.Errorf("FAIL:\nactualSpecific: %#v\nexpectSpecific: %#v", + actualSpecific, expectSpecific) + } +} + +func TestGetAcctEntitlementsMockOpaClient(t *testing.T) { + testMap := []struct { + name string + regoRespJSON string + expectErr bool + expectedVal *az.AcctEntitlementsType + }{ + { + name: `valid result`, + regoRespJSON: `{ "result": { + "acct1": { "svc1a": [ "feat1a1", "feat1a2" ] }, + "acct2": { "svc2a": [ "feat2a1", "feat2a2" ], + "svc2b": [ "feat2b1", "feat2b2" ] } + }}`, + expectErr: false, + expectedVal: &az.AcctEntitlementsType{ + "acct1": {"svc1a": {"feat1a1", "feat1a2"}}, + "acct2": {"svc2a": {"feat2a1", "feat2a2"}, + "svc2b": {"feat2b1", "feat2b2"}}, + }, + }, + { + name: `null result ok`, + regoRespJSON: `{ "result": null }`, + expectErr: false, + expectedVal: nil, + }, + { + name: `null account entitled service ok`, + regoRespJSON: `{ "result": { + "acct1": { "svc1a": [ "feat1a1", "feat1a2" ] }, + "acct2": null + }}`, + expectErr: false, + expectedVal: &az.AcctEntitlementsType{ + "acct1": {"svc1a": {"feat1a1", "feat1a2"}}, + "acct2": nil, + }, + }, + { + name: `null service entitled features ok`, + regoRespJSON: `{ "result": { + "acct2": { "svc2a": null, + "svc2b": [ "feat2b1", "feat2b2" ] } + }}`, + expectErr: false, + expectedVal: &az.AcctEntitlementsType{ + "acct2": {"svc2a": nil, + "svc2b": {"feat2b1", "feat2b2"}}, + }, + }, + { + name: `incorrect result type`, + regoRespJSON: `[ null ]`, + expectErr: true, + expectedVal: nil, + }, + { + name: `no result key`, + regoRespJSON: `{ "rresult": null }`, + expectErr: false, + expectedVal: nil, + }, + { + name: `invalid result array`, + regoRespJSON: `{ "result": [ 1, 2 ] }`, + expectErr: true, + expectedVal: nil, + }, + { + name: `invalid account entitled service`, + regoRespJSON: `{ "result": { + "acct2": { "svc2a": [ "feat2a1", "feat2a2" ], + "svc2b": {} } + }}`, + expectErr: true, + expectedVal: nil, + }, + { + name: `invalid service entitled feature`, + regoRespJSON: `{ "result": { + "acct2": { "svc2a": [ "feat2a1", "feat2a2" ], + "svc2b": [ "feat2b1", 31415926 ] } + }}`, + expectErr: true, + expectedVal: nil, + }, + } + + stdLoggr := logrus.StandardLogger() + ctx := context.WithValue(context.Background(), utils_test.TestingTContextKey, t) + ctx = ctxlogrus.ToContext(ctx, logrus.NewEntry(stdLoggr)) + + for nth, tm := range testMap { + mockOpaClienter := utils_test.MockOpaClienter{ + Loggr: stdLoggr, + RegoRespJSON: tm.regoRespJSON, + } + auther := NewHttpAuthorizer("bogus_unused_application_value", + WithOpaClienter(&mockOpaClienter), + ) + + actualVal, actualErr := auther.GetAcctEntitlements(ctx, nil, nil) + t.Logf("%d: %q: actualErr=%#v, actualVal=%#v", nth, tm.name, actualVal, actualErr) + + if tm.expectErr && actualErr == nil { + t.Errorf("%d: %q: FAIL: expected err, but got no err", nth, tm.name) + } else if !tm.expectErr && actualErr != nil { + t.Errorf("%d: %q: FAIL: got unexpected err=%s", nth, tm.name, actualErr) + } + + if actualErr != nil && actualVal != nil { + t.Errorf("%d: %q: FAIL: returned val should be nil if err returned", nth, tm.name) + } + + if !reflect.DeepEqual(actualVal, tm.expectedVal) { + t.Errorf("%d: %q: FAIL: expectedVal=%#v actualVal=%#v", + nth, tm.name, tm.expectedVal, actualVal) + } + } +} diff --git a/http_opa/authorizer.go b/http_opa/authorizer.go index 6ab11af..256ac1e 100644 --- a/http_opa/authorizer.go +++ b/http_opa/authorizer.go @@ -9,7 +9,6 @@ import ( "time" "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" - "github.com/infobloxopen/atlas-authz-middleware/v2/common" az "github.com/infobloxopen/atlas-authz-middleware/v2/common/authorizer" commonClaim "github.com/infobloxopen/atlas-authz-middleware/v2/common/claim" "github.com/infobloxopen/atlas-authz-middleware/v2/common/opautil" @@ -24,14 +23,15 @@ var SERVICENAME = "opa" // httpAuthorizer is an implementation of the az.Authorizer interface for HTTP-based authorization using OPA. type httpAuthorizer struct { - application string - clienter opa_client.Clienter - opaEvaluator az.OpaEvaluator - decisionInputHandler az.DecisionInputHandler - claimsVerifier az.ClaimsVerifier - entitledServices []string - acctEntitlementsApi string - endpointModifier *EndpointModifier + application string + clienter opa_client.Clienter + opaEvaluator az.OpaEvaluator + decisionInputHandler az.DecisionInputHandler + claimsVerifier az.ClaimsVerifier + entitledServices []string + acctEntitlementsApi string + currUserCompartmentsApi string + endpointModifier *EndpointModifier } var defDecisionInputer = new(az.DefaultDecisionInputer) @@ -40,10 +40,11 @@ var defDecisionInputer = new(az.DefaultDecisionInputer) func NewHttpAuthorizer(application string, opts ...Option) az.Authorizer { // Configuration options for the authorizer cfg := &Config{ - address: opa_client.DefaultAddress, - decisionInputHandler: defDecisionInputer, - claimsVerifier: commonClaim.UnverifiedClaimFromBearers, - acctEntitlementsApi: common.DefaultAcctEntitlementsApiPath, + address: opa_client.DefaultAddress, + decisionInputHandler: defDecisionInputer, + claimsVerifier: commonClaim.UnverifiedClaimFromBearers, + acctEntitlementsApi: az.DefaultAcctEntitlementsApiPath, + currUserCompartmentsApi: az.DefaultCurrentUserCompartmentsPath, } for _, opt := range opts { opt(cfg) @@ -55,14 +56,15 @@ func NewHttpAuthorizer(application string, opts ...Option) az.Authorizer { } a := httpAuthorizer{ - clienter: clienter, - opaEvaluator: cfg.opaEvaluator, - application: application, - decisionInputHandler: cfg.decisionInputHandler, - claimsVerifier: cfg.claimsVerifier, - entitledServices: cfg.entitledServices, - acctEntitlementsApi: cfg.acctEntitlementsApi, - endpointModifier: cfg.endpointModifier, + clienter: clienter, + opaEvaluator: cfg.opaEvaluator, + application: application, + decisionInputHandler: cfg.decisionInputHandler, + claimsVerifier: cfg.claimsVerifier, + entitledServices: cfg.entitledServices, + acctEntitlementsApi: cfg.acctEntitlementsApi, + currUserCompartmentsApi: cfg.currUserCompartmentsApi, + endpointModifier: cfg.endpointModifier, } return &a } diff --git a/http_opa/compartments.go b/http_opa/compartments.go new file mode 100644 index 0000000..05efd63 --- /dev/null +++ b/http_opa/compartments.go @@ -0,0 +1,58 @@ +package httpopa + +import ( + "context" + "fmt" + + "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" + commonClaim "github.com/infobloxopen/atlas-authz-middleware/v2/common/claim" + "github.com/infobloxopen/atlas-authz-middleware/v2/common/opautil" + atlas_claims "github.com/infobloxopen/atlas-claims" + logrus "github.com/sirupsen/logrus" +) + +// 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 *httpAuthorizer) 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 = commonClaim.UnverifiedClaimFromBearers + } + + rawJWT, errs := claimsVerifier([]string{bearer}, []string{newBearer}) + if len(errs) > 0 { + return nil, fmt.Errorf("%q", errs) + } + + opaReq := opautil.OPARequest{ + Input: &opautil.Payload{ + JWT: opautil.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/http_opa/compartments_test.go b/http_opa/compartments_test.go new file mode 100644 index 0000000..8649d37 --- /dev/null +++ b/http_opa/compartments_test.go @@ -0,0 +1,193 @@ +package httpopa + +import ( + "context" + "io/ioutil" + "reflect" + "sort" + "testing" + "time" + + "github.com/infobloxopen/atlas-authz-middleware/v2/pkg/opa_client" + "github.com/infobloxopen/atlas-authz-middleware/v2/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 := NewHttpAuthorizer("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 := NewHttpAuthorizer("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/http_opa/config.go b/http_opa/config.go index c875df8..d8ef6cf 100644 --- a/http_opa/config.go +++ b/http_opa/config.go @@ -44,14 +44,15 @@ type Config struct { // address to opa address string - clienter opa_client.Clienter - opaEvaluator az.OpaEvaluator - authorizer []az.Authorizer - decisionInputHandler az.DecisionInputHandler - claimsVerifier az.ClaimsVerifier - entitledServices []string - acctEntitlementsApi string - endpointModifier *EndpointModifier + clienter opa_client.Clienter + opaEvaluator az.OpaEvaluator + authorizer []az.Authorizer + decisionInputHandler az.DecisionInputHandler + claimsVerifier az.ClaimsVerifier + entitledServices []string + acctEntitlementsApi string + currUserCompartmentsApi string + endpointModifier *EndpointModifier } func (c Config) GetAuthorizer() []az.Authorizer { @@ -154,6 +155,13 @@ func WithAcctEntitlementsApiPath(acctEntitlementsApi string) Option { } } +// WithCurrentUserCompartmentsPath overrides default CurrentUserCompartmentsApiPath +func WithCurrentUserCompartmentsPath(currUserCompartmentsApi string) Option { + return func(c *Config) { + c.currUserCompartmentsApi = currUserCompartmentsApi + } +} + // WithAcctSegmentsNeeded overrides default 0 func WithEndpointModifier(modifier *EndpointModifier) Option { return func(c *Config) { diff --git a/http_opa/testdata/mock_authz_policy.rego b/http_opa/testdata/mock_authz_policy.rego new file mode 100644 index 0000000..53ad113 --- /dev/null +++ b/http_opa/testdata/mock_authz_policy.rego @@ -0,0 +1,269 @@ +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, +} + +acct_entitlements_acct_ids_is_empty { + not input.acct_entitlements_acct_ids +} + +acct_entitlements_acct_ids_is_empty { + is_array(input.acct_entitlements_acct_ids) + count(input.acct_entitlements_acct_ids) == 0 +} + +acct_entitlements_services_is_empty { + not input.acct_entitlements_services +} + +acct_entitlements_services_is_empty { + is_array(input.acct_entitlements_services) + count(input.acct_entitlements_services) == 0 +} + +acct_entitlements_api = acct_ent_result { + # No filtering, get all acct_entitlements for all acct_entitlements_acct_ids + acct_entitlements_acct_ids_is_empty + acct_entitlements_services_is_empty + acct_ent_result := account_service_features +} else = acct_ent_result { + acct_ent_result := acct_entitlements_filtered_api +} + +# Get acct_entitlements by specific acct_entitlements_acct_ids +# and specific acct_entitlements_services +acct_entitlements_filtered_api[acct_id] = acct_ent { + is_array(input.acct_entitlements_acct_ids) + count(input.acct_entitlements_acct_ids) > 0 + is_array(input.acct_entitlements_services) + count(input.acct_entitlements_services) > 0 + acct_id := input.acct_entitlements_acct_ids[_] + acct_ent := {ent_svc_name: ent_svc_feats | + ent_svc_name := input.acct_entitlements_services[_] + ent_svc_feats := account_service_features[acct_id][ent_svc_name] + } +} + +account_service_features := { + "2001016": { + "environment": [ + "ac", + "heated-seats", + ], + "wheel": [ + "abs", + "alloy", + "tpms", + ], + }, + "2001040": { + "environment": [ + "ac", + "side-mirror-defogger", + ], + "powertrain": [ + "automatic", + "turbo", + ], + }, + "2001230": { + "powertrain": [ + "manual", + "v8", + ], + "wheel": [ + "run-flat", + ], + }, +} + +test_acct_entitlements_api_no_input { + results := acct_entitlements_api + trace(sprintf("results: %v", [results])) + results == account_service_features +} + +test_acct_entitlements_api_empty_input { + results := acct_entitlements_api with input as { + "acct_entitlements_acct_ids": [], + "acct_entitlements_services": [], + } + trace(sprintf("results: %v", [results])) + results == account_service_features +} + +test_acct_entitlements_api_with_input { + results := acct_entitlements_api with input as { + "acct_entitlements_acct_ids": ["2001040", "2001230"], + "acct_entitlements_services": ["powertrain", "wheel"], + } + trace(sprintf("results: %v", [results])) + results == { + "2001040": { + "powertrain": [ + "automatic", + "turbo", + ], + }, + "2001230": { + "powertrain": [ + "manual", + "v8", + ], + "wheel": [ + "run-flat", + ], + }, + } +} + +group_compartment_roles := { + "40": { + "all-resources": { + "custom-admin-group": { + "root-compartment": [ + "custom-admin-role", + "administrator-role" + ], + "compartment-40-red.": [ + "devops-role", + "secops-role" + ] + }, + "user-group-40": { + "root-compartment": [ + "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": { + "root-compartment": [ + "custom-admin-role", + "administrator-role" + ], + "compartment-16-red.": [ + "devops-role", + "secops-role" + ] + }, + "user-group-16": { + "root-compartment": [ + "custom-admin-role", + "administrator-role" + ], + "compartment-16-red.": [ + "devops-role", + "secops-role" + ] + }, + "user": { + "compartment-16-green.": [ + "readonly-role" + ] + } + } + }, + "3101": { + "all-resources": { + "devops-group": { + "root-compartment": [ + "widget-role-read" + ], + "red.": [ + "widget-role-create", + "gadget-role-create" + ], + "green.": [ + "widget-role-update" + ], + "green.car.": [ + "gizmo-role-create" + ] + }, + "secops-group": { + "root-compartment": [ + "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 := "root-compartment" + +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."}) +} + +# 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 . +# curl -X POST -H 'Content-Type: application/json' http://localhost:8181/v1/data/authz/rbac/acct_entitlements_api | jq . + diff --git a/http_opa/testdata/mock_system_main.rego b/http_opa/testdata/mock_system_main.rego new file mode 100644 index 0000000..9ed1925 --- /dev/null +++ b/http_opa/testdata/mock_system_main.rego @@ -0,0 +1,42 @@ +# OPA POST query without url path will query OPA's configured default decision document. +# By default the default decision document is /data/system/main. +# ( See https://www.openpolicyagent.org/docs/v0.29.4/rest-api/#query-api ) +# +# This test rego defines a dummy authz policy for /data/system/main. +# It verifies that queries with and without url path: +# - requires different input document formats +# - returns different response document formats +# +# $ opa run --server mock_system_main.rego +# +# POST query WITHOUT url path against unspecified default decision document +# Notice that: +# - the input document must NOT be encapsulated inside "input" +# - the return document is NOT encapsulated inside "result" +# $ curl -X POST -H 'Content-Type: application/json' http://localhost:8181/ -d '{"application": "automobile", "endpoint": "Vehicle.StompGasPedal"}' | jq . +# ==> returns {"allow": true} +# $ curl -X POST -H 'Content-Type: application/json' http://localhost:8181/ -d '{"input": {"application": "automobile", "endpoint": "Vehicle.StompGasPedal"}}' | jq . +# ==> returns {"allow": false} +# +# POST query WITH url path against any explicitly specified decision document +# Notice that: +# - the input document MUST be encapsulated inside "input" +# - the return document is ALWAYS encapsulated inside "result" +# $ curl -X POST -H 'Content-Type: application/json' http://localhost:8181/v1/data/system/main -d '{"application": "automobile", "endpoint": "Vehicle.StompGasPedal"}' | jq . +# ==> returns {"result": {"allow": false}} +# $ curl -X POST -H 'Content-Type: application/json' http://localhost:8181/v1/data/system/main -d '{"input": {"application": "automobile", "endpoint": "Vehicle.StompGasPedal"}}' | jq . +# ==> returns {"result": {"allow": true}} +# + +package system + +default allow = false + +allow { + input.application == "automobile" + input.endpoint == "Vehicle.StompGasPedal" +} + +main = { + "allow": allow, +} 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..bc2ffaa --- /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/v2/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 +}