From a4052f441c340a67a4065e3562dc1c20a2b0ea4e Mon Sep 17 00:00:00 2001 From: Rahmat Hidayat Date: Mon, 20 Feb 2023 17:29:47 +0700 Subject: [PATCH] feat: v0.6.0 (#366) * feat(idtoken-auth): add auth validator using google idtoken (#349) * docs: add idtoken validation on sample config.yaml * feat: add auth config to replace idtoken validator config, and refactor default auth header key * refactor: move auth interceptor to pkg/auth * chore: delete idtoken validator mock * feat: keep config.AuthenticatedUserHeaderKey for backward-compatibility * chore: re-order import * refactor: change sample config.yaml for auth config * chore: make auth user header key sample and default config consistent * chore: re-order import on oidc test * fix: change oidc initialism * fix: oidc validator mocks renaming * fix: avoid using params with pointer, as it will lead to panic if params is nil * fix: detect old auth user header key using empty string instead of default tag * refactor: move OIDCAuth to pkg/auth * refactor: move oidc validator mocks into pkg/auth/mocks * chore: add deprecation notes on AuthenticatedUserHeaderKey * refactor: make default auth email context key back to unexported, use different context key for oidc email * refactor: do not use default header key on oidc auth, use its own header (it's only used for logrus) * refactor: move logrus context custom fields to new interceptor and retrieve its value from context * refactor: auth email context key mapping for default and oidc used for grpc server * test: change ways of getting user email from request header to context * refactor: use auth.OIDCAuth instead of OIDCValidatorParams * feat(gcs): support import grants for gcs provider (#360) * feat(gcs): support import grants for gcs provider * refactor(gcs): use projectID value from struct field and remove from GetBuckets' param * refactor(gcs): refactor repeated logic to retrieve gcs client * refactor(gcs): improve code readability * fix(gcs): exclude deleted principals (#364) * refactor(gcs): use account type whitelisting * fix(gcs): exclude deleted principals * fix(gcs): fix timeout by running getPolicy in parallel (#365) --------- Co-authored-by: Pulung Ragil --- api/handler/v1beta1/appeal_test.go | 42 +----- api/handler/v1beta1/approval_test.go | 36 +----- api/handler/v1beta1/grpc.go | 39 +++--- api/handler/v1beta1/grpc_test.go | 7 +- internal/server/auth.go | 18 ++- internal/server/config.go | 37 ++++-- internal/server/config.yaml | 11 +- internal/server/server.go | 38 +++++- internal/server/services.go | 26 +++- pkg/auth/mocks/OIDCValidator.go | 53 ++++++++ pkg/auth/oidc.go | 99 +++++++++++++++ pkg/auth/oidc_test.go | 155 +++++++++++++++++++++++ plugins/providers/gcs/client.go | 83 ++++++++++-- plugins/providers/gcs/config.go | 9 ++ plugins/providers/gcs/mocks/Crypto.go | 41 +++++- plugins/providers/gcs/mocks/GCSClient.go | 137 ++++++++++++++++---- plugins/providers/gcs/provider.go | 65 +++++----- plugins/providers/gcs/provider_test.go | 51 +++++++- 18 files changed, 761 insertions(+), 186 deletions(-) create mode 100644 pkg/auth/mocks/OIDCValidator.go create mode 100644 pkg/auth/oidc.go create mode 100644 pkg/auth/oidc_test.go diff --git a/api/handler/v1beta1/appeal_test.go b/api/handler/v1beta1/appeal_test.go index fa3b837ac..c01b97e1e 100644 --- a/api/handler/v1beta1/appeal_test.go +++ b/api/handler/v1beta1/appeal_test.go @@ -128,11 +128,7 @@ func (s *GrpcHandlersSuite) TestListUserAppeals() { ResourceUrns: []string{"test-resource-urn"}, OrderBy: []string{"test-order"}, } - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: expectedUser, - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, expectedUser) res, err := s.grpcServer.ListUserAppeals(ctx, req) s.NoError(err) @@ -162,11 +158,7 @@ func (s *GrpcHandlersSuite) TestListUserAppeals() { Return(nil, expectedError).Once() req := &guardianv1beta1.ListUserAppealsRequest{} - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: "test-user", - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, "test-user") res, err := s.grpcServer.ListUserAppeals(ctx, req) s.Equal(codes.Internal, status.Code(err)) @@ -188,11 +180,7 @@ func (s *GrpcHandlersSuite) TestListUserAppeals() { Return(invalidAppeals, nil).Once() req := &guardianv1beta1.ListUserAppealsRequest{} - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: "test-user", - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, "test-user") res, err := s.grpcServer.ListUserAppeals(ctx, req) s.Equal(codes.Internal, status.Code(err)) @@ -484,11 +472,7 @@ func (s *GrpcHandlersSuite) TestCreateAppeal() { }, Description: "The answer is 42", } - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: expectedUser, - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, expectedUser) res, err := s.grpcServer.CreateAppeal(ctx, req) s.NoError(err) @@ -520,11 +504,7 @@ func (s *GrpcHandlersSuite) TestCreateAppeal() { s.appealService.EXPECT().Create(mock.AnythingOfType("*context.valueCtx"), mock.Anything).Return(appeal.ErrAppealDuplicate).Once() req := &guardianv1beta1.CreateAppealRequest{} - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: "user@example.com", - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, "user@example.com") res, err := s.grpcServer.CreateAppeal(ctx, req) s.Equal(codes.AlreadyExists, status.Code(err)) @@ -539,11 +519,7 @@ func (s *GrpcHandlersSuite) TestCreateAppeal() { s.appealService.EXPECT().Create(mock.AnythingOfType("*context.valueCtx"), mock.Anything).Return(expectedError).Once() req := &guardianv1beta1.CreateAppealRequest{} - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: "user@example.com", - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, "user@example.com") res, err := s.grpcServer.CreateAppeal(ctx, req) s.Equal(codes.Internal, status.Code(err)) @@ -567,11 +543,7 @@ func (s *GrpcHandlersSuite) TestCreateAppeal() { Return(nil).Once() req := &guardianv1beta1.CreateAppealRequest{Resources: make([]*guardianv1beta1.CreateAppealRequest_Resource, 1)} - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: "user@example.com", - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, "user@example.com") res, err := s.grpcServer.CreateAppeal(ctx, req) s.Equal(codes.Internal, status.Code(err)) diff --git a/api/handler/v1beta1/approval_test.go b/api/handler/v1beta1/approval_test.go index 227d28592..57bb3660b 100644 --- a/api/handler/v1beta1/approval_test.go +++ b/api/handler/v1beta1/approval_test.go @@ -116,11 +116,7 @@ func (s *GrpcHandlersSuite) TestListUserApprovals() { Statuses: []string{"active", "pending"}, OrderBy: []string{"test-order"}, } - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: expectedUser, - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, expectedUser) res, err := s.grpcServer.ListUserApprovals(ctx, req) s.NoError(err) @@ -150,11 +146,7 @@ func (s *GrpcHandlersSuite) TestListUserApprovals() { Return(nil, expectedError).Once() req := &guardianv1beta1.ListUserApprovalsRequest{} - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: "test-user", - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, "test-user") res, err := s.grpcServer.ListUserApprovals(ctx, req) s.Equal(codes.Internal, status.Code(err)) @@ -178,11 +170,7 @@ func (s *GrpcHandlersSuite) TestListUserApprovals() { Return(invalidApprovals, nil).Once() req := &guardianv1beta1.ListUserApprovalsRequest{} - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: "test-user", - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, "test-user") res, err := s.grpcServer.ListUserApprovals(ctx, req) s.Equal(codes.Internal, status.Code(err)) @@ -443,11 +431,7 @@ func (s *GrpcHandlersSuite) TestUpdateApproval() { Reason: expectedReason, }, } - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: expectedUser, - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, expectedUser) res, err := s.grpcServer.UpdateApproval(ctx, req) s.NoError(err) @@ -553,11 +537,7 @@ func (s *GrpcHandlersSuite) TestUpdateApproval() { Return(nil, tc.expectedError).Once() req := &guardianv1beta1.UpdateApprovalRequest{} - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: expectedUser, - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, expectedUser) res, err := s.grpcServer.UpdateApproval(ctx, req) s.Equal(tc.expectedStatusCode, status.Code(err)) @@ -579,11 +559,7 @@ func (s *GrpcHandlersSuite) TestUpdateApproval() { Return(invalidAppeal, nil).Once() req := &guardianv1beta1.UpdateApprovalRequest{} - ctx := context.Background() - md := metadata.New(map[string]string{ - s.authenticatedUserHeaderKey: "user@example.com", - }) - ctx = metadata.NewIncomingContext(ctx, md) + ctx := context.WithValue(context.Background(), authEmailTestContextKey{}, "user@example.com") res, err := s.grpcServer.UpdateApproval(ctx, req) s.Equal(codes.Internal, status.Code(err)) diff --git a/api/handler/v1beta1/grpc.go b/api/handler/v1beta1/grpc.go index afe66ea75..8aed90424 100644 --- a/api/handler/v1beta1/grpc.go +++ b/api/handler/v1beta1/grpc.go @@ -2,14 +2,15 @@ package v1beta1 import ( "context" - "errors" + "strings" "github.com/odpf/guardian/core/appeal" "github.com/odpf/guardian/core/grant" guardianv1beta1 "github.com/odpf/guardian/api/proto/odpf/guardian/v1beta1" "github.com/odpf/guardian/domain" - "google.golang.org/grpc/metadata" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) type ProtoAdapter interface { @@ -116,7 +117,7 @@ type GRPCServer struct { grantService grantService adapter ProtoAdapter - authenticatedUserHeaderKey string + authenticatedUserContextKey interface{} guardianv1beta1.UnimplementedGuardianServiceServer } @@ -130,32 +131,30 @@ func NewGRPCServer( approvalService approvalService, grantService grantService, adapter ProtoAdapter, - authenticatedUserHeaderKey string, + authenticatedUserContextKey interface{}, ) *GRPCServer { return &GRPCServer{ - resourceService: resourceService, - activityService: activityService, - providerService: providerService, - policyService: policyService, - appealService: appealService, - approvalService: approvalService, - grantService: grantService, - adapter: adapter, - authenticatedUserHeaderKey: authenticatedUserHeaderKey, + resourceService: resourceService, + activityService: activityService, + providerService: providerService, + policyService: policyService, + appealService: appealService, + approvalService: approvalService, + grantService: grantService, + adapter: adapter, + authenticatedUserContextKey: authenticatedUserContextKey, } } func (s *GRPCServer) getUser(ctx context.Context) (string, error) { - md, ok := metadata.FromIncomingContext(ctx) + authenticatedEmail, ok := ctx.Value(s.authenticatedUserContextKey).(string) if !ok { - return "", errors.New("unable to retrieve metadata from context") + return "", status.Error(codes.Unauthenticated, "unable to get authenticated user from context") } - users := md.Get(s.authenticatedUserHeaderKey) - if len(users) == 0 { - return "", errors.New("user email not found") + if strings.TrimSpace(authenticatedEmail) == "" { + return "", status.Error(codes.Unauthenticated, "unable to get authenticated user from context") } - currentUser := users[0] - return currentUser, nil + return authenticatedEmail, nil } diff --git a/api/handler/v1beta1/grpc_test.go b/api/handler/v1beta1/grpc_test.go index 72e68234f..a223e7b5e 100644 --- a/api/handler/v1beta1/grpc_test.go +++ b/api/handler/v1beta1/grpc_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/suite" ) +type authEmailTestContextKey struct{} + type GrpcHandlersSuite struct { suite.Suite @@ -19,8 +21,6 @@ type GrpcHandlersSuite struct { approvalService *mocks.ApprovalService grantService *mocks.GrantService grpcServer *v1beta1.GRPCServer - - authenticatedUserHeaderKey string } func TestGrpcHandler(t *testing.T) { @@ -35,7 +35,6 @@ func (s *GrpcHandlersSuite) setup() { s.appealService = new(mocks.AppealService) s.approvalService = new(mocks.ApprovalService) s.grantService = new(mocks.GrantService) - s.authenticatedUserHeaderKey = "test-header-key" s.grpcServer = v1beta1.NewGRPCServer( s.resourceService, s.activityService, @@ -45,6 +44,6 @@ func (s *GrpcHandlersSuite) setup() { s.approvalService, s.grantService, v1beta1.NewAdapter(), - s.authenticatedUserHeaderKey, + authEmailTestContextKey{}, ) } diff --git a/internal/server/auth.go b/internal/server/auth.go index 07c45bedb..915feff0e 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -11,19 +11,29 @@ import ( type authenticatedUserEmailContextKey struct{} +var logrusActorKey = "actor" + func withAuthenticatedUserEmail(headerKey string) grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { if md, ok := metadata.FromIncomingContext(ctx); ok { if v := md.Get(headerKey); len(v) > 0 { userEmail := v[0] ctx = context.WithValue(ctx, authenticatedUserEmailContextKey{}, userEmail) - - ctx_logrus.AddFields(ctx, logrus.Fields{ - headerKey: userEmail, - }) } } return handler(ctx, req) } } + +func withLogrusContext() grpc.UnaryServerInterceptor { + return func(ctx context.Context, req interface{}, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + if userEmail, ok := ctx.Value(authenticatedUserEmailContextKey{}).(string); ok { + ctx_logrus.AddFields(ctx, logrus.Fields{ + logrusActorKey: userEmail, + }) + } + + return handler(ctx, req) + } +} diff --git a/internal/server/config.go b/internal/server/config.go index 0caa423a1..1e4490b24 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/odpf/guardian/internal/store" + "github.com/odpf/guardian/pkg/auth" "github.com/odpf/guardian/pkg/tracing" "github.com/odpf/guardian/plugins/notifiers" "github.com/odpf/salt/config" @@ -33,16 +34,28 @@ type Jobs struct { ExpiringAccessNotification JobConfig `mapstructure:"expiring_access_notification"` } +type DefaultAuth struct { + HeaderKey string `mapstructure:"header_key" default:"X-Auth-Email"` +} + +type Auth struct { + Provider string `mapstructure:"provider" default:"default"` + Default DefaultAuth `mapstructure:"default"` + OIDC auth.OIDCAuth `mapstructure:"oidc"` +} + type Config struct { - Port int `mapstructure:"port" default:"8080"` - EncryptionSecretKeyKey string `mapstructure:"encryption_secret_key"` - Notifier notifiers.Config `mapstructure:"notifier"` - LogLevel string `mapstructure:"log_level" default:"info"` - DB store.Config `mapstructure:"db"` - AuthenticatedUserHeaderKey string `mapstructure:"authenticated_user_header_key"` - AuditLogTraceIDHeaderKey string `mapstructure:"audit_log_trace_id_header_key" default:"X-Trace-Id"` - Jobs Jobs `mapstructure:"jobs"` - Telemetry tracing.Config `mapstructure:"telemetry"` + Port int `mapstructure:"port" default:"8080"` + EncryptionSecretKeyKey string `mapstructure:"encryption_secret_key"` + Notifier notifiers.Config `mapstructure:"notifier"` + LogLevel string `mapstructure:"log_level" default:"info"` + DB store.Config `mapstructure:"db"` + // Deprecated: use Auth.Default.HeaderKey instead note on the AuthenticatedUserHeaderKey + AuthenticatedUserHeaderKey string `mapstructure:"authenticated_user_header_key"` + AuditLogTraceIDHeaderKey string `mapstructure:"audit_log_trace_id_header_key" default:"X-Trace-Id"` + Jobs Jobs `mapstructure:"jobs"` + Telemetry tracing.Config `mapstructure:"telemetry"` + Auth Auth `mapstructure:"auth"` } func LoadConfig(configFile string) (Config, error) { @@ -56,5 +69,11 @@ func LoadConfig(configFile string) (Config, error) { } return Config{}, err } + + // keep for backward-compatibility + if cfg.AuthenticatedUserHeaderKey != "" { + cfg.Auth.Default.HeaderKey = cfg.AuthenticatedUserHeaderKey + } + return cfg, nil } diff --git a/internal/server/config.yaml b/internal/server/config.yaml index c7b78059b..f2250e925 100644 --- a/internal/server/config.yaml +++ b/internal/server/config.yaml @@ -11,7 +11,7 @@ PORT: 3000 ENCRYPTION_SECRET_KEY: -AUTHENTICATED_USER_HEADER_KEY: X-User-Email +AUTHENTICATED_USER_HEADER_KEY: X-Auth-Email LOG: LEVEL: info DB: @@ -42,4 +42,11 @@ TELEMETRY: OTLP: HEADERS: api-key: - ENDPOINT: "otlp.nr-data.net:4317" \ No newline at end of file + ENDPOINT: "otlp.nr-data.net:4317" +AUTH: + PROVIDER: default # can be "default" or "oidc" + DEFAULT: + HEADER_KEY: X-Auth-Email # AUTHENTICATED_USER_HEADER_KEY takes priority for backward-compatibility + OIDC: + AUDIENCE: "some-kind-of-audience.com" + ELIGIBLE_EMAIL_DOMAINS: "emaildomain1.com,emaildomain2.com" \ No newline at end of file diff --git a/internal/server/server.go b/internal/server/server.go index b37efe7a1..b9ff34241 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -20,6 +20,7 @@ import ( guardianv1beta1 "github.com/odpf/guardian/api/proto/odpf/guardian/v1beta1" "github.com/odpf/guardian/internal/store/postgres" "github.com/odpf/guardian/jobs" + "github.com/odpf/guardian/pkg/auth" "github.com/odpf/guardian/pkg/crypto" "github.com/odpf/guardian/pkg/scheduler" "github.com/odpf/guardian/pkg/tracing" @@ -30,6 +31,7 @@ import ( "github.com/sirupsen/logrus" "github.com/uptrace/opentelemetry-go-extra/otelgorm" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "google.golang.org/api/idtoken" "google.golang.org/grpc" "google.golang.org/protobuf/encoding/protojson" ) @@ -104,6 +106,12 @@ func RunServer(config *Config) error { // init grpc server logrusEntry := logrus.NewEntry(logrus.New()) // TODO: get logrus instance from `logger` var + + authInterceptor, err := getAuthInterceptor(config) + if err != nil { + return err + } + grpcServer := grpc.NewServer( grpc.StreamInterceptor(grpc_middleware.ChainStreamServer( grpc_logrus.StreamServerInterceptor(logrusEntry), @@ -117,10 +125,17 @@ func RunServer(config *Config) error { }), ), grpc_logrus.UnaryServerInterceptor(logrusEntry), - withAuthenticatedUserEmail(config.AuthenticatedUserHeaderKey), + authInterceptor, + withLogrusContext(), otelgrpc.UnaryServerInterceptor(), )), ) + + authUserContextKey := map[string]interface{}{ + "default": authenticatedUserEmailContextKey{}, + "oidc": auth.OIDCEmailContextKey{}, + } + protoAdapter := handlerv1beta1.NewAdapter() guardianv1beta1.RegisterGuardianServiceServer(grpcServer, handlerv1beta1.NewGRPCServer( services.ResourceService, @@ -131,7 +146,7 @@ func RunServer(config *Config) error { services.ApprovalService, services.GrantService, protoAdapter, - config.AuthenticatedUserHeaderKey, + authUserContextKey[config.Auth.Provider], )) // init http proxy @@ -218,7 +233,7 @@ func makeHeaderMatcher(c *Config) func(key string) (string, bool) { return func(key string) (string, bool) { switch strings.ToLower(key) { case - strings.ToLower(c.AuthenticatedUserHeaderKey), + strings.ToLower(c.Auth.Default.HeaderKey), strings.ToLower(c.AuditLogTraceIDHeaderKey): return key, true default: @@ -266,3 +281,20 @@ func fetchDefaultJobScheduleMapping() map[JobType]string { ExpiringGrantNotification: "0 9 * * *", } } + +func getAuthInterceptor(config *Config) (grpc.UnaryServerInterceptor, error) { + // default fallback to user email on header + authInterceptor := withAuthenticatedUserEmail(config.Auth.Default.HeaderKey) + + if config.Auth.Provider == "oidc" { + idtokenValidator, err := idtoken.NewValidator(context.Background()) + if err != nil { + return nil, err + } + + bearerTokenValidator := auth.NewOIDCValidator(idtokenValidator, config.Auth.OIDC) + authInterceptor = bearerTokenValidator.WithOIDCValidator() + } + + return authInterceptor, nil +} diff --git a/internal/server/services.go b/internal/server/services.go index dc74b0df2..ef2007f9d 100644 --- a/internal/server/services.go +++ b/internal/server/services.go @@ -17,6 +17,7 @@ import ( "github.com/odpf/guardian/core/resource" "github.com/odpf/guardian/domain" "github.com/odpf/guardian/internal/store/postgres" + "github.com/odpf/guardian/pkg/auth" "github.com/odpf/guardian/plugins/identities" "github.com/odpf/guardian/plugins/notifiers" "github.com/odpf/guardian/plugins/providers/bigquery" @@ -66,6 +67,8 @@ func InitServices(deps ServiceDeps) (*Services, error) { auditRepository := audit_repos.NewPostgresRepository(sqldb) auditRepository.Init(context.TODO()) + actorExtractor := getActorExtractor(deps.Config) + auditLogger := audit.New( audit.WithRepository(auditRepository), audit.WithMetadataExtractor(func(ctx context.Context) map[string]interface{} { @@ -88,12 +91,7 @@ func InitServices(deps ServiceDeps) (*Services, error) { return md }), - audit.WithActorExtractor(func(ctx context.Context) (string, error) { - if actor, ok := ctx.Value(authenticatedUserEmailContextKey{}).(string); ok { - return actor, nil - } - return "", nil - }), + actorExtractor, ) activityRepository := postgres.NewActivityRepository(store.DB()) @@ -184,3 +182,19 @@ func InitServices(deps ServiceDeps) (*Services, error) { grantService, }, nil } + +func getActorExtractor(config *Config) audit.AuditOption { + var contextKey interface{} + + contextKey = authenticatedUserEmailContextKey{} + if config.Auth.Provider == "oidc" { + contextKey = auth.OIDCEmailContextKey{} + } + + return audit.WithActorExtractor(func(ctx context.Context) (string, error) { + if actor, ok := ctx.Value(contextKey).(string); ok { + return actor, nil + } + return "", nil + }) +} diff --git a/pkg/auth/mocks/OIDCValidator.go b/pkg/auth/mocks/OIDCValidator.go new file mode 100644 index 000000000..62bb30ba3 --- /dev/null +++ b/pkg/auth/mocks/OIDCValidator.go @@ -0,0 +1,53 @@ +// Code generated by mockery v2.16.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + idtoken "google.golang.org/api/idtoken" +) + +// OIDCValidator is an autogenerated mock type for the Validator type +type OIDCValidator struct { + mock.Mock +} + +// Validate provides a mock function with given fields: ctx, token, audience +func (_m *OIDCValidator) Validate(ctx context.Context, token string, audience string) (*idtoken.Payload, error) { + ret := _m.Called(ctx, token, audience) + + var r0 *idtoken.Payload + if rf, ok := ret.Get(0).(func(context.Context, string, string) *idtoken.Payload); ok { + r0 = rf(ctx, token, audience) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*idtoken.Payload) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, token, audience) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type mockConstructorTestingTNewOIDCValidator interface { + mock.TestingT + Cleanup(func()) +} + +// NewOIDCValidator creates a new instance of OIDCValidator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewOIDCValidator(t mockConstructorTestingTNewOIDCValidator) *OIDCValidator { + mock := &OIDCValidator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go new file mode 100644 index 000000000..19da043dc --- /dev/null +++ b/pkg/auth/oidc.go @@ -0,0 +1,99 @@ +package auth + +import ( + "context" + "strings" + + "google.golang.org/api/idtoken" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +var InvalidAuthError = status.Errorf(codes.Unauthenticated, "invalid authentication credentials") + +type Validator interface { + Validate(ctx context.Context, token string, audience string) (*idtoken.Payload, error) +} + +type OIDCEmailContextKey struct{} + +type OIDCAuth struct { + Audience string `mapstructure:"audience"` + EligibleEmailDomains string `mapstructure:"eligible_email_domains"` +} + +type OIDCValidator struct { + validator Validator + audience string + validEmailDomains []string +} + +func NewOIDCValidator(validator Validator, config OIDCAuth) *OIDCValidator { + audience := config.Audience + + var validEmailDomains []string + if strings.TrimSpace(config.EligibleEmailDomains) != "" { + validEmailDomains = strings.Split(config.EligibleEmailDomains, ",") + } + + return &OIDCValidator{ + validator: validator, + audience: audience, + validEmailDomains: validEmailDomains, + } +} + +func (v *OIDCValidator) WithOIDCValidator() grpc.UnaryServerInterceptor { + return func(ctx context.Context, req interface{}, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, InvalidAuthError + } + + headerValue := md.Get("authorization") + if len(headerValue) == 0 || strings.TrimSpace(headerValue[0]) == "" { + return nil, InvalidAuthError + } + + bearerToken := strings.TrimSpace(strings.TrimPrefix(headerValue[0], "Bearer ")) + if len(bearerToken) == 0 { + return nil, InvalidAuthError + } + + payload, err := v.validator.Validate(ctx, bearerToken, v.audience) + if err != nil { + return nil, InvalidAuthError + } + + email := payload.Claims["email"].(string) + if err := v.validateEmailDomain(email); err != nil { + return nil, err + } + + ctx = context.WithValue(ctx, OIDCEmailContextKey{}, email) + + return handler(ctx, req) + } +} + +func (v *OIDCValidator) validateEmailDomain(email string) error { + // no valid email domains listed means that no email domain will be checked + if len(v.validEmailDomains) == 0 { + return nil + } + + emailDomainMatch := false + for _, validEmailDomain := range v.validEmailDomains { + if strings.HasSuffix(email, "@"+validEmailDomain) { + emailDomainMatch = true + break + } + } + + if !emailDomainMatch { + return InvalidAuthError + } + return nil +} diff --git a/pkg/auth/oidc_test.go b/pkg/auth/oidc_test.go new file mode 100644 index 000000000..273fe979d --- /dev/null +++ b/pkg/auth/oidc_test.go @@ -0,0 +1,155 @@ +package auth_test + +import ( + "context" + "errors" + "testing" + + "github.com/odpf/guardian/pkg/auth" + "github.com/odpf/guardian/pkg/auth/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "google.golang.org/api/idtoken" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +var authContextValues = map[string]string{ + "Authorization": "Bearer some-bearer-token-in-JWT", +} + +type InterceptorTestSuite struct { + suite.Suite +} + +func (s *InterceptorTestSuite) TestIdTokenValidator_WithBearerTokenValidator() { + emptyAuthContextValues := map[string]string{ + "Authorization": "Bearer ", + } + + testCases := []struct { + name string + params auth.OIDCAuth + ctx context.Context + mockFunc func(validator *mocks.OIDCValidator) + expectedErr error + }{ + { + name: "MD context value does not exist", + params: auth.OIDCAuth{}, + ctx: context.Background(), + mockFunc: func(validator *mocks.OIDCValidator) {}, + expectedErr: auth.InvalidAuthError, + }, + { + name: "empty authorization header", + params: auth.OIDCAuth{}, + ctx: metadata.NewIncomingContext(context.Background(), metadata.New(map[string]string{})), + mockFunc: func(validator *mocks.OIDCValidator) {}, + expectedErr: auth.InvalidAuthError, + }, + { + name: "empty bearer token on authorization header", + params: auth.OIDCAuth{}, + ctx: metadata.NewIncomingContext(context.Background(), metadata.New(emptyAuthContextValues)), + mockFunc: func(validator *mocks.OIDCValidator) {}, + expectedErr: auth.InvalidAuthError, + }, + { + name: "error while validating token", + params: auth.OIDCAuth{ + Audience: "google.com", + }, + ctx: metadata.NewIncomingContext(context.Background(), metadata.New(authContextValues)), + mockFunc: func(validator *mocks.OIDCValidator) { + validator.On("Validate", mock.Anything, mock.Anything, "google.com"). + Return(nil, errors.New("something happened")) + }, + expectedErr: auth.InvalidAuthError, + }, + { + name: "email domain does not match with eligible domains", + params: auth.OIDCAuth{ + Audience: "google.com", + EligibleEmailDomains: "example.com,something.org", + }, + ctx: metadata.NewIncomingContext(context.Background(), metadata.New(authContextValues)), + mockFunc: func(validator *mocks.OIDCValidator) { + + payload := &idtoken.Payload{ + Claims: map[string]interface{}{ + "email": "something@gmail.com", + }, + } + validator.On("Validate", mock.Anything, mock.Anything, "google.com"). + Return(payload, nil) + }, + expectedErr: auth.InvalidAuthError, + }, + { + name: "successful request with matching eligible email domains", + params: auth.OIDCAuth{ + Audience: "google.com", + EligibleEmailDomains: "example.com,something.org", + }, + ctx: metadata.NewIncomingContext(context.Background(), metadata.New(authContextValues)), + mockFunc: func(validator *mocks.OIDCValidator) { + payload := &idtoken.Payload{ + Claims: map[string]interface{}{ + "email": "something@example.com", + }, + } + validator.On("Validate", mock.Anything, mock.Anything, "google.com"). + Return(payload, nil) + }, + expectedErr: nil, + }, + { + name: "successful request with no eligible email domains configurations whatsoever", + params: auth.OIDCAuth{ + Audience: "google.com", + }, + ctx: metadata.NewIncomingContext(context.Background(), metadata.New(authContextValues)), + mockFunc: func(validator *mocks.OIDCValidator) { + payload := &idtoken.Payload{ + Claims: map[string]interface{}{ + "email": "something@example.com", + }, + } + validator.On("Validate", mock.Anything, mock.Anything, "google.com"). + Return(payload, nil) + }, + expectedErr: nil, + }, + } + + var req interface{} + + for _, tc := range testCases { + s.Run(tc.name, func() { + validator := new(mocks.OIDCValidator) + authValidator := auth.NewOIDCValidator(validator, tc.params) + interceptFunc := authValidator.WithOIDCValidator() + + tc.mockFunc(validator) + result, err := interceptFunc(tc.ctx, req, &grpc.UnaryServerInfo{}, s.unaryDummyHandler) + + assert.Nil(s.T(), result) + assert.Equal(s.T(), tc.expectedErr, err) + }) + } +} + +func (suite *InterceptorTestSuite) unaryDummyHandler(ctx context.Context, _ interface{}) (interface{}, error) { + expectedCtx := metadata.NewIncomingContext(context.Background(), metadata.New(authContextValues)) + expectedCtx = context.WithValue(expectedCtx, auth.OIDCEmailContextKey{}, "something@example.com") + + assert.Equal(suite.T(), expectedCtx, ctx, "final method handler doesn't have matching context") + + return nil, nil +} + +func TestOidcValidatorInterceptor(t *testing.T) { + suite.Run(t, new(InterceptorTestSuite)) +} diff --git a/plugins/providers/gcs/client.go b/plugins/providers/gcs/client.go index 963876e0f..f03dd787e 100644 --- a/plugins/providers/gcs/client.go +++ b/plugins/providers/gcs/client.go @@ -3,20 +3,17 @@ package gcs import ( "context" "fmt" + "strings" "cloud.google.com/go/iam" "cloud.google.com/go/storage" + "github.com/odpf/guardian/domain" + "github.com/odpf/guardian/utils" + "golang.org/x/sync/errgroup" "google.golang.org/api/iterator" "google.golang.org/api/option" ) -//go:generate mockery --name=GCSClient --exported --with-expecter -type GCSClient interface { - GetBuckets(ctx context.Context, projectID string) ([]*Bucket, error) - GrantBucketAccess(ctx context.Context, b Bucket, identity string, roleName iam.RoleName) error - RevokeBucketAccess(ctx context.Context, b Bucket, identity string, roleName iam.RoleName) error -} - type gcsClient struct { client *storage.Client projectID string @@ -34,10 +31,10 @@ func newGCSClient(projectID string, credentialsJSON []byte) (*gcsClient, error) }, nil } -// GetBuckets returns all buckets within a given project -func (c *gcsClient) GetBuckets(ctx context.Context, projectID string) ([]*Bucket, error) { +// GetBuckets returns all buckets in the project +func (c *gcsClient) GetBuckets(ctx context.Context) ([]*Bucket, error) { var result []*Bucket - it := c.client.Buckets(ctx, projectID) + it := c.client.Buckets(ctx, c.projectID) for { battrs, err := it.Next() if err == iterator.Done { @@ -84,3 +81,69 @@ func (c *gcsClient) RevokeBucketAccess(ctx context.Context, b Bucket, identity s return nil } + +func (c *gcsClient) ListAccess(ctx context.Context, resources []*domain.Resource) (domain.MapResourceAccess, error) { + result := make(domain.MapResourceAccess) + eg, ctx := errgroup.WithContext(ctx) + + for _, resource := range resources { + resource := resource + eg.Go(func() error { + var accessEntries []domain.AccessEntry + + bucket := c.client.Bucket(resource.URN) + policy, err := bucket.IAM().Policy(ctx) + if err != nil { + return fmt.Errorf("Bucket(%q).IAM().Policy: %w", resource.URN, err) + } + + for _, role := range policy.Roles() { + for _, member := range policy.Members(role) { + if strings.HasPrefix(member, "deleted:") { + continue + } + accountType, accountID, err := parseMember(member) + if err != nil { + return err + } + + // exclude unsupported account types + if !utils.ContainsString(AllowedAccountTypes, accountType) { + continue + } + + accessEntries = append(accessEntries, domain.AccessEntry{ + Permission: string(role), + AccountID: accountID, + AccountType: accountType, + }) + } + } + + if accessEntries != nil { + result[resource.URN] = accessEntries + } + + return nil + }) + } + if err := eg.Wait(); err != nil { + return nil, err + } + + return result, nil +} + +func parseMember(member string) (accountType, accountID string, err error) { + m := strings.Split(member, ":") + if len(m) == 0 || len(m) > 2 { + return "", "", fmt.Errorf("invalid bucket access member signature %q", member) + } + + if len(m) == 2 { + accountID = m[1] + } + accountType = m[0] + + return accountType, accountID, nil +} diff --git a/plugins/providers/gcs/config.go b/plugins/providers/gcs/config.go index 614b555ce..fa10cfc40 100644 --- a/plugins/providers/gcs/config.go +++ b/plugins/providers/gcs/config.go @@ -28,6 +28,15 @@ const ( AccountTypeDomain = "domain" ) +var ( + AllowedAccountTypes = []string{ + AccountTypeUser, + AccountTypeServiceAccount, + AccountTypeGroup, + AccountTypeDomain, + } +) + type Config struct { ProviderConfig *domain.ProviderConfig diff --git a/plugins/providers/gcs/mocks/Crypto.go b/plugins/providers/gcs/mocks/Crypto.go index bb7be8860..2e1b0c165 100644 --- a/plugins/providers/gcs/mocks/Crypto.go +++ b/plugins/providers/gcs/mocks/Crypto.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.10.0. DO NOT EDIT. +// Code generated by mockery v2.20.0. DO NOT EDIT. package mocks @@ -22,13 +22,16 @@ func (_m *Crypto) Decrypt(_a0 string) (string, error) { ret := _m.Called(_a0) var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(_a0) + } if rf, ok := ret.Get(0).(func(string) string); ok { r0 = rf(_a0) } else { r0 = ret.Get(0).(string) } - var r1 error if rf, ok := ret.Get(1).(func(string) error); ok { r1 = rf(_a0) } else { @@ -44,7 +47,7 @@ type Crypto_Decrypt_Call struct { } // Decrypt is a helper method to define mock.On call -// - _a0 string +// - _a0 string func (_e *Crypto_Expecter) Decrypt(_a0 interface{}) *Crypto_Decrypt_Call { return &Crypto_Decrypt_Call{Call: _e.mock.On("Decrypt", _a0)} } @@ -61,18 +64,26 @@ func (_c *Crypto_Decrypt_Call) Return(_a0 string, _a1 error) *Crypto_Decrypt_Cal return _c } +func (_c *Crypto_Decrypt_Call) RunAndReturn(run func(string) (string, error)) *Crypto_Decrypt_Call { + _c.Call.Return(run) + return _c +} + // Encrypt provides a mock function with given fields: _a0 func (_m *Crypto) Encrypt(_a0 string) (string, error) { ret := _m.Called(_a0) var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(_a0) + } if rf, ok := ret.Get(0).(func(string) string); ok { r0 = rf(_a0) } else { r0 = ret.Get(0).(string) } - var r1 error if rf, ok := ret.Get(1).(func(string) error); ok { r1 = rf(_a0) } else { @@ -88,7 +99,7 @@ type Crypto_Encrypt_Call struct { } // Encrypt is a helper method to define mock.On call -// - _a0 string +// - _a0 string func (_e *Crypto_Expecter) Encrypt(_a0 interface{}) *Crypto_Encrypt_Call { return &Crypto_Encrypt_Call{Call: _e.mock.On("Encrypt", _a0)} } @@ -104,3 +115,23 @@ func (_c *Crypto_Encrypt_Call) Return(_a0 string, _a1 error) *Crypto_Encrypt_Cal _c.Call.Return(_a0, _a1) return _c } + +func (_c *Crypto_Encrypt_Call) RunAndReturn(run func(string) (string, error)) *Crypto_Encrypt_Call { + _c.Call.Return(run) + return _c +} + +type mockConstructorTestingTNewCrypto interface { + mock.TestingT + Cleanup(func()) +} + +// NewCrypto creates a new instance of Crypto. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewCrypto(t mockConstructorTestingTNewCrypto) *Crypto { + mock := &Crypto{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/plugins/providers/gcs/mocks/GCSClient.go b/plugins/providers/gcs/mocks/GCSClient.go index 86f06ca4a..522600f6d 100644 --- a/plugins/providers/gcs/mocks/GCSClient.go +++ b/plugins/providers/gcs/mocks/GCSClient.go @@ -1,13 +1,15 @@ -// Code generated by mockery v2.10.0. DO NOT EDIT. +// Code generated by mockery v2.20.0. DO NOT EDIT. package mocks import ( context "context" - iam "cloud.google.com/go/iam" + domain "github.com/odpf/guardian/domain" gcs "github.com/odpf/guardian/plugins/providers/gcs" + iam "cloud.google.com/go/iam" + mock "github.com/stretchr/testify/mock" ) @@ -24,22 +26,25 @@ func (_m *GCSClient) EXPECT() *GCSClient_Expecter { return &GCSClient_Expecter{mock: &_m.Mock} } -// GetBuckets provides a mock function with given fields: ctx, projectID -func (_m *GCSClient) GetBuckets(ctx context.Context, projectID string) ([]*gcs.Bucket, error) { - ret := _m.Called(ctx, projectID) +// GetBuckets provides a mock function with given fields: _a0 +func (_m *GCSClient) GetBuckets(_a0 context.Context) ([]*gcs.Bucket, error) { + ret := _m.Called(_a0) var r0 []*gcs.Bucket - if rf, ok := ret.Get(0).(func(context.Context, string) []*gcs.Bucket); ok { - r0 = rf(ctx, projectID) + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) ([]*gcs.Bucket, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(context.Context) []*gcs.Bucket); ok { + r0 = rf(_a0) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*gcs.Bucket) } } - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = rf(ctx, projectID) + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(_a0) } else { r1 = ret.Error(1) } @@ -53,15 +58,14 @@ type GCSClient_GetBuckets_Call struct { } // GetBuckets is a helper method to define mock.On call -// - ctx context.Context -// - projectID string -func (_e *GCSClient_Expecter) GetBuckets(ctx interface{}, projectID interface{}) *GCSClient_GetBuckets_Call { - return &GCSClient_GetBuckets_Call{Call: _e.mock.On("GetBuckets", ctx, projectID)} +// - _a0 context.Context +func (_e *GCSClient_Expecter) GetBuckets(_a0 interface{}) *GCSClient_GetBuckets_Call { + return &GCSClient_GetBuckets_Call{Call: _e.mock.On("GetBuckets", _a0)} } -func (_c *GCSClient_GetBuckets_Call) Run(run func(ctx context.Context, projectID string)) *GCSClient_GetBuckets_Call { +func (_c *GCSClient_GetBuckets_Call) Run(run func(_a0 context.Context)) *GCSClient_GetBuckets_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string)) + run(args[0].(context.Context)) }) return _c } @@ -71,6 +75,11 @@ func (_c *GCSClient_GetBuckets_Call) Return(_a0 []*gcs.Bucket, _a1 error) *GCSCl return _c } +func (_c *GCSClient_GetBuckets_Call) RunAndReturn(run func(context.Context) ([]*gcs.Bucket, error)) *GCSClient_GetBuckets_Call { + _c.Call.Return(run) + return _c +} + // GrantBucketAccess provides a mock function with given fields: ctx, b, identity, roleName func (_m *GCSClient) GrantBucketAccess(ctx context.Context, b gcs.Bucket, identity string, roleName iam.RoleName) error { ret := _m.Called(ctx, b, identity, roleName) @@ -91,10 +100,10 @@ type GCSClient_GrantBucketAccess_Call struct { } // GrantBucketAccess is a helper method to define mock.On call -// - ctx context.Context -// - b gcs.Bucket -// - identity string -// - roleName iam.RoleName +// - ctx context.Context +// - b gcs.Bucket +// - identity string +// - roleName iam.RoleName func (_e *GCSClient_Expecter) GrantBucketAccess(ctx interface{}, b interface{}, identity interface{}, roleName interface{}) *GCSClient_GrantBucketAccess_Call { return &GCSClient_GrantBucketAccess_Call{Call: _e.mock.On("GrantBucketAccess", ctx, b, identity, roleName)} } @@ -111,6 +120,66 @@ func (_c *GCSClient_GrantBucketAccess_Call) Return(_a0 error) *GCSClient_GrantBu return _c } +func (_c *GCSClient_GrantBucketAccess_Call) RunAndReturn(run func(context.Context, gcs.Bucket, string, iam.RoleName) error) *GCSClient_GrantBucketAccess_Call { + _c.Call.Return(run) + return _c +} + +// ListAccess provides a mock function with given fields: _a0, _a1 +func (_m *GCSClient) ListAccess(_a0 context.Context, _a1 []*domain.Resource) (domain.MapResourceAccess, error) { + ret := _m.Called(_a0, _a1) + + var r0 domain.MapResourceAccess + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []*domain.Resource) (domain.MapResourceAccess, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, []*domain.Resource) domain.MapResourceAccess); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(domain.MapResourceAccess) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, []*domain.Resource) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GCSClient_ListAccess_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAccess' +type GCSClient_ListAccess_Call struct { + *mock.Call +} + +// ListAccess is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 []*domain.Resource +func (_e *GCSClient_Expecter) ListAccess(_a0 interface{}, _a1 interface{}) *GCSClient_ListAccess_Call { + return &GCSClient_ListAccess_Call{Call: _e.mock.On("ListAccess", _a0, _a1)} +} + +func (_c *GCSClient_ListAccess_Call) Run(run func(_a0 context.Context, _a1 []*domain.Resource)) *GCSClient_ListAccess_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].([]*domain.Resource)) + }) + return _c +} + +func (_c *GCSClient_ListAccess_Call) Return(_a0 domain.MapResourceAccess, _a1 error) *GCSClient_ListAccess_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *GCSClient_ListAccess_Call) RunAndReturn(run func(context.Context, []*domain.Resource) (domain.MapResourceAccess, error)) *GCSClient_ListAccess_Call { + _c.Call.Return(run) + return _c +} + // RevokeBucketAccess provides a mock function with given fields: ctx, b, identity, roleName func (_m *GCSClient) RevokeBucketAccess(ctx context.Context, b gcs.Bucket, identity string, roleName iam.RoleName) error { ret := _m.Called(ctx, b, identity, roleName) @@ -131,10 +200,10 @@ type GCSClient_RevokeBucketAccess_Call struct { } // RevokeBucketAccess is a helper method to define mock.On call -// - ctx context.Context -// - b gcs.Bucket -// - identity string -// - roleName iam.RoleName +// - ctx context.Context +// - b gcs.Bucket +// - identity string +// - roleName iam.RoleName func (_e *GCSClient_Expecter) RevokeBucketAccess(ctx interface{}, b interface{}, identity interface{}, roleName interface{}) *GCSClient_RevokeBucketAccess_Call { return &GCSClient_RevokeBucketAccess_Call{Call: _e.mock.On("RevokeBucketAccess", ctx, b, identity, roleName)} } @@ -150,3 +219,23 @@ func (_c *GCSClient_RevokeBucketAccess_Call) Return(_a0 error) *GCSClient_Revoke _c.Call.Return(_a0) return _c } + +func (_c *GCSClient_RevokeBucketAccess_Call) RunAndReturn(run func(context.Context, gcs.Bucket, string, iam.RoleName) error) *GCSClient_RevokeBucketAccess_Call { + _c.Call.Return(run) + return _c +} + +type mockConstructorTestingTNewGCSClient interface { + mock.TestingT + Cleanup(func()) +} + +// NewGCSClient creates a new instance of GCSClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewGCSClient(t mockConstructorTestingTNewGCSClient) *GCSClient { + mock := &GCSClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/plugins/providers/gcs/provider.go b/plugins/providers/gcs/provider.go index 3cb9e74eb..5d5b706b7 100644 --- a/plugins/providers/gcs/provider.go +++ b/plugins/providers/gcs/provider.go @@ -13,10 +13,19 @@ import ( "github.com/odpf/guardian/utils" ) +//go:generate mockery --name=GCSClient --exported --with-expecter +type GCSClient interface { + GetBuckets(context.Context) ([]*Bucket, error) + GrantBucketAccess(ctx context.Context, b Bucket, identity string, roleName iam.RoleName) error + RevokeBucketAccess(ctx context.Context, b Bucket, identity string, roleName iam.RoleName) error + ListAccess(context.Context, []*domain.Resource) (domain.MapResourceAccess, error) +} + //go:generate mockery --name=Crypto --exported --with-expecter type Crypto interface { domain.Crypto } + type Provider struct { provider.UnimplementedClient provider.PermissionManager @@ -55,16 +64,7 @@ func (p *Provider) CreateConfig(pc *domain.ProviderConfig) error { } func (p *Provider) GetResources(pc *domain.ProviderConfig) ([]*domain.Resource, error) { - var creds Credentials - if err := mapstructure.Decode(pc.Credentials, &creds); err != nil { - return nil, err - } - - if err := creds.Decrypt(p.crypto); err != nil { - return nil, err - } - - client, err := p.getGCSClient(creds) + client, err := p.getGCSClient(*pc) if err != nil { return nil, err } @@ -75,8 +75,7 @@ func (p *Provider) GetResources(pc *domain.ProviderConfig) ([]*domain.Resource, } var resources []*domain.Resource - projectID := strings.Replace(creds.ResourceName, "projects/", "", 1) - buckets, err := client.GetBuckets(context.TODO(), projectID) + buckets, err := client.GetBuckets(context.TODO()) if err != nil { return nil, err } @@ -103,16 +102,7 @@ func (p *Provider) GrantAccess(pc *domain.ProviderConfig, a domain.Grant) error permissions := getPermissions(a) - var creds Credentials - if err := mapstructure.Decode(pc.Credentials, &creds); err != nil { - return fmt.Errorf("error in decoding credentials%w", err) - } - - if err := creds.Decrypt(p.crypto); err != nil { - return fmt.Errorf("error in decrypting credentials%w", err) - } - - client, err := p.getGCSClient(creds) + client, err := p.getGCSClient(*pc) if err != nil { return fmt.Errorf("error in getting new client: %w", err) } @@ -144,16 +134,7 @@ func (p *Provider) RevokeAccess(pc *domain.ProviderConfig, a domain.Grant) error permissions := getPermissions(a) - var creds Credentials - if err := mapstructure.Decode(pc.Credentials, &creds); err != nil { - return fmt.Errorf("error in decoding credentials%w", err) - } - - if err := creds.Decrypt(p.crypto); err != nil { - return fmt.Errorf("error in decrypting credentials%w", err) - } - - client, err := p.getGCSClient(creds) + client, err := p.getGCSClient(*pc) if err != nil { return fmt.Errorf("error in getting new client: %w", err) } @@ -211,7 +192,25 @@ func getPermissions(a domain.Grant) []Permission { return permissions } -func (p *Provider) getGCSClient(creds Credentials) (GCSClient, error) { +func (p *Provider) ListAccess(ctx context.Context, pc domain.ProviderConfig, resources []*domain.Resource) (domain.MapResourceAccess, error) { + client, err := p.getGCSClient(pc) + if err != nil { + return nil, err + } + + return client.ListAccess(ctx, resources) +} + +func (p *Provider) getGCSClient(pc domain.ProviderConfig) (GCSClient, error) { + var creds Credentials + if err := mapstructure.Decode(pc.Credentials, &creds); err != nil { + return nil, fmt.Errorf("decoding credentials: %w", err) + } + + if err := creds.Decrypt(p.crypto); err != nil { + return nil, fmt.Errorf("decrypting credentials: %w", err) + } + projectID := strings.Replace(creds.ResourceName, "projects/", "", 1) if p.Clients[projectID] != nil { return p.Clients[projectID], nil diff --git a/plugins/providers/gcs/provider_test.go b/plugins/providers/gcs/provider_test.go index 25760c726..0f623b017 100644 --- a/plugins/providers/gcs/provider_test.go +++ b/plugins/providers/gcs/provider_test.go @@ -1,6 +1,7 @@ package gcs_test import ( + "context" "encoding/base64" "errors" "fmt" @@ -250,7 +251,7 @@ func TestGetResources(t *testing.T) { Name: "test-bucket-name", }, } - client.On("GetBuckets", mock.Anything, mock.Anything).Return(expectedBuckets, nil).Once() + client.On("GetBuckets", mock.Anything).Return(expectedBuckets, nil).Once() expectedResources := []*domain.Resource{ { ProviderType: pc.Type, @@ -828,6 +829,54 @@ func TestGetAccountType(t *testing.T) { }) } +func TestListAccess(t *testing.T) { + crypto := new(mocks.Crypto) + client := new(mocks.GCSClient) + p := gcs.NewProvider("gcs", crypto) + providerURN := "test-resource-name" + p.Clients = map[string]gcs.GCSClient{ + providerURN: client, + } + + saKey := "service_account-key-json" + encryptedSAKey := "encrypted-service_account-key-json" + dummyProviderConfig := &domain.ProviderConfig{ + Type: domain.ProviderTypeGCS, + URN: providerURN, + Credentials: gcs.Credentials{ + ServiceAccountKey: encryptedSAKey, + ResourceName: "projects/test-resource-name", + }, + Resources: []*domain.ResourceConfig{ + { + Type: gcs.ResourceTypeBucket, + Roles: []*domain.Role{ + { + ID: "Storage Legacy Bucket Writer", + Name: "Storage Legacy Bucket Writer", + Description: "Read access to buckets with object listing/creation/deletion", + Permissions: []interface{}{"roles/storage.legacyBucketWriter"}, + }, + }, + }, + }, + } + + crypto.EXPECT().Decrypt(encryptedSAKey).Return(saKey, nil).Once() + dummyResources := []*domain.Resource{} + expectedResourcesAccess := domain.MapResourceAccess{} + client.EXPECT(). + ListAccess(mock.AnythingOfType("*context.emptyCtx"), dummyResources). + Return(expectedResourcesAccess, nil).Once() + + actualResourcesAccess, err := p.ListAccess(context.Background(), *dummyProviderConfig, dummyResources) + + assert.Nil(t, err) + assert.Equal(t, expectedResourcesAccess, actualResourcesAccess) + crypto.AssertExpectations(t) + client.AssertExpectations(t) +} + func initProvider() *gcs.Provider { crypto := new(mocks.Crypto) return gcs.NewProvider("gcs", crypto)