diff --git a/core/appeal/mocks/providerService.go b/core/appeal/mocks/providerService.go index 95d4d8929..877675400 100644 --- a/core/appeal/mocks/providerService.go +++ b/core/appeal/mocks/providerService.go @@ -176,6 +176,50 @@ func (_c *ProviderService_GrantAccess_Call) RunAndReturn(run func(context.Contex return _c } +// IsExclusiveRoleAssignment provides a mock function with given fields: _a0, _a1, _a2 +func (_m *ProviderService) IsExclusiveRoleAssignment(_a0 context.Context, _a1 string, _a2 string) bool { + ret := _m.Called(_a0, _a1, _a2) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, string, string) bool); ok { + r0 = rf(_a0, _a1, _a2) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// ProviderService_IsExclusiveRoleAssignment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsExclusiveRoleAssignment' +type ProviderService_IsExclusiveRoleAssignment_Call struct { + *mock.Call +} + +// IsExclusiveRoleAssignment is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 string +// - _a2 string +func (_e *ProviderService_Expecter) IsExclusiveRoleAssignment(_a0 interface{}, _a1 interface{}, _a2 interface{}) *ProviderService_IsExclusiveRoleAssignment_Call { + return &ProviderService_IsExclusiveRoleAssignment_Call{Call: _e.mock.On("IsExclusiveRoleAssignment", _a0, _a1, _a2)} +} + +func (_c *ProviderService_IsExclusiveRoleAssignment_Call) Run(run func(_a0 context.Context, _a1 string, _a2 string)) *ProviderService_IsExclusiveRoleAssignment_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *ProviderService_IsExclusiveRoleAssignment_Call) Return(_a0 bool) *ProviderService_IsExclusiveRoleAssignment_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ProviderService_IsExclusiveRoleAssignment_Call) RunAndReturn(run func(context.Context, string, string) bool) *ProviderService_IsExclusiveRoleAssignment_Call { + _c.Call.Return(run) + return _c +} + // RevokeAccess provides a mock function with given fields: _a0, _a1 func (_m *ProviderService) RevokeAccess(_a0 context.Context, _a1 domain.Grant) error { ret := _m.Called(_a0, _a1) diff --git a/core/appeal/service.go b/core/appeal/service.go index 328233631..bb8f7e877 100644 --- a/core/appeal/service.go +++ b/core/appeal/service.go @@ -28,6 +28,7 @@ const ( AuditKeyDeleteApprover = "appeal.deleteApprover" RevokeReasonForExtension = "Automatically revoked for grant extension" + RevokeReasonForOverride = "Automatically revoked for grant override" ) var TimeNow = time.Now @@ -70,6 +71,7 @@ type providerService interface { RevokeAccess(context.Context, domain.Grant) error ValidateAppeal(context.Context, *domain.Appeal, *domain.Provider, *domain.Policy) error GetPermissions(context.Context, *domain.ProviderConfig, string, string) ([]interface{}, error) + IsExclusiveRoleAssignment(context.Context, string, string) bool } //go:generate mockery --name=resourceService --exported --with-expecter @@ -284,7 +286,7 @@ func (s *Service) Create(ctx context.Context, appeals []*domain.Appeal, opts ... newGrant.Resource = appeal.Resource appeal.Grant = newGrant if revokedGrant != nil { - if _, err := s.grantService.Revoke(ctx, revokedGrant.ID, domain.SystemActorName, RevokeReasonForExtension, + if _, err := s.grantService.Revoke(ctx, revokedGrant.ID, domain.SystemActorName, revokedGrant.RevokeReason, grant.SkipNotifications(), grant.SkipRevokeAccessInProvider(), ); err != nil { @@ -511,7 +513,7 @@ func (s *Service) UpdateApproval(ctx context.Context, approvalAction domain.Appr newGrant.Resource = appeal.Resource appeal.Grant = newGrant if revokedGrant != nil { - if _, err := s.grantService.Revoke(ctx, revokedGrant.ID, domain.SystemActorName, RevokeReasonForExtension, + if _, err := s.grantService.Revoke(ctx, revokedGrant.ID, domain.SystemActorName, revokedGrant.RevokeReason, grant.SkipNotifications(), grant.SkipRevokeAccessInProvider(), ); err != nil { @@ -1186,20 +1188,28 @@ func (s *Service) getPermissions(ctx context.Context, pc *domain.ProviderConfig, return strPermissions, nil } +// TODO(feature): add relation between new and revoked grant for traceability func (s *Service) prepareGrant(ctx context.Context, appeal *domain.Appeal) (newGrant *domain.Grant, deactivatedGrant *domain.Grant, err error) { - activeGrants, err := s.grantService.List(ctx, domain.ListGrantsFilter{ + filter := domain.ListGrantsFilter{ AccountIDs: []string{appeal.AccountID}, ResourceIDs: []string{appeal.ResourceID}, Statuses: []string{string(domain.GrantStatusActive)}, Permissions: appeal.Permissions, - }) + } + revocationReason := RevokeReasonForExtension + if s.providerService.IsExclusiveRoleAssignment(ctx, appeal.Resource.ProviderType, appeal.Resource.Type) { + filter.Permissions = nil + revocationReason = RevokeReasonForOverride + } + + activeGrants, err := s.grantService.List(ctx, filter) if err != nil { return nil, nil, fmt.Errorf("unable to retrieve existing active grants: %w", err) } if len(activeGrants) > 0 { deactivatedGrant = &activeGrants[0] - if err := deactivatedGrant.Revoke(domain.SystemActorName, "Extended to a new grant"); err != nil { + if err := deactivatedGrant.Revoke(domain.SystemActorName, revocationReason); err != nil { return nil, nil, fmt.Errorf("revoking previous grant: %w", err) } } diff --git a/core/appeal/service_test.go b/core/appeal/service_test.go index 2e5998aa1..3fbdfe67a 100644 --- a/core/appeal/service_test.go +++ b/core/appeal/service_test.go @@ -1082,6 +1082,9 @@ func (s *ServiceTestSuite) TestCreate() { Return(nil).Once() s.mockNotifier.On("Notify", mock.MatchedBy(func(ctx context.Context) bool { return true }), mock.Anything).Return(nil).Once() s.mockAuditLogger.On("Log", mock.Anything, appeal.AuditKeyBulkInsert, mock.Anything).Return(nil).Once() + s.mockProviderService.EXPECT(). + IsExclusiveRoleAssignment(mock.Anything, mock.Anything, mock.Anything). + Return(false).Once() s.mockGrantService.On("List", mock.Anything, mock.Anything).Return([]domain.Grant{}, nil).Once() s.mockGrantService.On("Prepare", mock.Anything, mock.Anything).Return(&domain.Grant{}, nil).Once() s.mockPolicyService.On("GetOne", mock.Anything, mock.Anything, mock.Anything).Return(overriddingPolicy, nil).Once() @@ -1315,6 +1318,9 @@ func (s *ServiceTestSuite) TestCreateAppeal__WithExistingAppealAndWithAutoApprov }). Return(expectedExistingGrants, nil).Once() // duplicate call with slight change in filters but the code needs it in order to work. appeal create code needs to be refactored. + s.mockProviderService.EXPECT(). + IsExclusiveRoleAssignment(mock.Anything, mock.Anything, mock.Anything). + Return(false).Once() s.mockGrantService.EXPECT(). List(mock.MatchedBy(func(ctx context.Context) bool { return true }), domain.ListGrantsFilter{ Statuses: []string{string(domain.GrantStatusActive)}, @@ -1330,6 +1336,9 @@ func (s *ServiceTestSuite) TestCreateAppeal__WithExistingAppealAndWithAutoApprov s.mockIAMManager.On("GetClient", mock.Anything).Return(s.mockIAMClient, nil) s.mockIAMClient.On("GetUser", accountID).Return(expectedCreatorUser, nil) + s.mockProviderService.EXPECT(). + IsExclusiveRoleAssignment(mock.Anything, mock.Anything, mock.Anything). + Return(false).Once() s.mockGrantService.EXPECT(). List(mock.MatchedBy(func(ctx context.Context) bool { return true }), domain.ListGrantsFilter{ AccountIDs: []string{accountID}, @@ -1507,6 +1516,9 @@ func (s *ServiceTestSuite) TestCreateAppeal__WithAdditionalAppeals() { s.mockResourceService.EXPECT().Find(mock.AnythingOfType("*context.cancelCtx"), expectedResourceFilters).Return([]*domain.Resource{resources[1]}, nil).Once() s.mockProviderService.EXPECT().Find(mock.AnythingOfType("*context.cancelCtx")).Return(providers, nil).Once() s.mockPolicyService.EXPECT().Find(mock.AnythingOfType("*context.cancelCtx")).Return(policies, nil).Once() + s.mockProviderService.EXPECT(). + IsExclusiveRoleAssignment(mock.Anything, mock.Anything, mock.Anything). + Return(false).Once() s.mockGrantService.EXPECT().List(mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("domain.ListGrantsFilter")).Return([]domain.Grant{}, nil).Once().Run(func(args mock.Arguments) { filter := args.Get(1).(domain.ListGrantsFilter) s.Equal([]string{appealsPayload[0].AccountID}, filter.AccountIDs) @@ -1521,6 +1533,9 @@ func (s *ServiceTestSuite) TestCreateAppeal__WithAdditionalAppeals() { s.Equal(targetResource.ID, appeal.ResourceID) }) s.mockProviderService.EXPECT().GetPermissions(mock.AnythingOfType("*context.cancelCtx"), providers[0].Config, resourceType, targetRole).Return([]interface{}{"test-permission-1"}, nil).Once() + s.mockProviderService.EXPECT(). + IsExclusiveRoleAssignment(mock.Anything, mock.Anything, mock.Anything). + Return(false).Once() s.mockGrantService.EXPECT().List(mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("domain.ListGrantsFilter")).Return([]domain.Grant{}, nil).Once().Run(func(args mock.Arguments) { filter := args.Get(1).(domain.ListGrantsFilter) s.Equal([]string{appealsPayload[0].AccountID}, filter.AccountIDs) @@ -1903,6 +1918,9 @@ func (s *ServiceTestSuite) TestUpdateApproval() { Return(appealDetails, nil).Once() s.mockPolicyService.EXPECT().GetOne(mock.Anything, mock.Anything, mock.Anything).Return(&domain.Policy{}, nil).Once() + s.mockProviderService.EXPECT(). + IsExclusiveRoleAssignment(mock.Anything, mock.Anything, mock.Anything). + Return(false).Once() s.mockGrantService.EXPECT(). List(mock.Anything, mock.Anything).Return(existingGrants, nil).Once() expectedNewGrant := &domain.Grant{ @@ -2299,6 +2317,9 @@ func (s *ServiceTestSuite) TestUpdateApproval() { Return(tc.expectedAppealDetails, nil).Once() if tc.expectedApprovalAction.Action == "approve" { + s.mockProviderService.EXPECT(). + IsExclusiveRoleAssignment(mock.Anything, mock.Anything, mock.Anything). + Return(false).Once() s.mockGrantService.EXPECT(). List(mock.Anything, domain.ListGrantsFilter{ AccountIDs: []string{tc.expectedAppealDetails.AccountID}, diff --git a/core/provider/mocks/assignmentTyper.go b/core/provider/mocks/assignmentTyper.go new file mode 100644 index 000000000..be6da8bc9 --- /dev/null +++ b/core/provider/mocks/assignmentTyper.go @@ -0,0 +1,78 @@ +// Code generated by mockery v2.33.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// AssignmentTyper is an autogenerated mock type for the assignmentTyper type +type AssignmentTyper struct { + mock.Mock +} + +type AssignmentTyper_Expecter struct { + mock *mock.Mock +} + +func (_m *AssignmentTyper) EXPECT() *AssignmentTyper_Expecter { + return &AssignmentTyper_Expecter{mock: &_m.Mock} +} + +// IsExclusiveRoleAssignment provides a mock function with given fields: _a0 +func (_m *AssignmentTyper) IsExclusiveRoleAssignment(_a0 context.Context) bool { + ret := _m.Called(_a0) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context) bool); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// AssignmentTyper_IsExclusiveRoleAssignment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsExclusiveRoleAssignment' +type AssignmentTyper_IsExclusiveRoleAssignment_Call struct { + *mock.Call +} + +// IsExclusiveRoleAssignment is a helper method to define mock.On call +// - _a0 context.Context +func (_e *AssignmentTyper_Expecter) IsExclusiveRoleAssignment(_a0 interface{}) *AssignmentTyper_IsExclusiveRoleAssignment_Call { + return &AssignmentTyper_IsExclusiveRoleAssignment_Call{Call: _e.mock.On("IsExclusiveRoleAssignment", _a0)} +} + +func (_c *AssignmentTyper_IsExclusiveRoleAssignment_Call) Run(run func(_a0 context.Context)) *AssignmentTyper_IsExclusiveRoleAssignment_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *AssignmentTyper_IsExclusiveRoleAssignment_Call) Return(_a0 bool) *AssignmentTyper_IsExclusiveRoleAssignment_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *AssignmentTyper_IsExclusiveRoleAssignment_Call) RunAndReturn(run func(context.Context) bool) *AssignmentTyper_IsExclusiveRoleAssignment_Call { + _c.Call.Return(run) + return _c +} + +// NewAssignmentTyper creates a new instance of AssignmentTyper. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAssignmentTyper(t interface { + mock.TestingT + Cleanup(func()) +}) *AssignmentTyper { + mock := &AssignmentTyper{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/provider/service.go b/core/provider/service.go index d30beb72a..884bb6e47 100644 --- a/core/provider/service.go +++ b/core/provider/service.go @@ -54,6 +54,11 @@ type dormancyChecker interface { CorrelateGrantActivities(context.Context, domain.Provider, []*domain.Grant, []*domain.Activity) error } +//go:generate mockery --name=assignmentTyper --exported --with-expecter +type assignmentTyper interface { + IsExclusiveRoleAssignment(context.Context) bool +} + //go:generate mockery --name=resourceService --exported --with-expecter type resourceService interface { Find(context.Context, domain.ListResourcesFilter) ([]*domain.Resource, error) @@ -253,8 +258,9 @@ func (s *Service) FetchResources(ctx context.Context) error { return err } - failedProviders := make([]string, 0) + failedProviders := map[string]error{} for _, p := range providers { + startTime := time.Now() s.logger.Info(ctx, "fetching resources", "provider_urn", p.URN) resources, err := s.getResources(ctx, p) if err != nil { @@ -263,15 +269,21 @@ func (s *Service) FetchResources(ctx context.Context) error { } s.logger.Info(ctx, "resources added", "provider_urn", p.URN, "count", len(flattenResources(resources))) if err := s.resourceService.BulkUpsert(ctx, resources); err != nil { - failedProviders = append(failedProviders, p.URN) + failedProviders[p.URN] = err s.logger.Error(ctx, "failed to add resources", "provider_urn", p.URN, "error", err) } + s.logger.Info(ctx, "fetching resources completed", "provider_urn", p.URN, "duration", time.Since(startTime)) } - if len(failedProviders) == 0 { - return nil + if len(failedProviders) > 0 { + var urns []string + for providerURN, err := range failedProviders { + s.logger.Error(ctx, "failed to add resources for provider", "provider_urn", providerURN, "error", err) + urns = append(urns, providerURN) + } + return fmt.Errorf("failed to add resources for providers: %v", urns) } - return fmt.Errorf("failed to add resources %s - %v", "providers", failedProviders) + return nil } func (s *Service) GetRoles(ctx context.Context, id string, resourceType string) ([]*domain.Role, error) { @@ -534,6 +546,16 @@ func (s *Service) CorrelateGrantActivities(ctx context.Context, p domain.Provide return activityClient.CorrelateGrantActivities(ctx, p, grants, activities) } +// IsExclusiveRoleAssignment returns true if the provider only supports exclusive role assignment +// i.e. a user can only have one role per resource +func (s *Service) IsExclusiveRoleAssignment(ctx context.Context, providerType, resourceType string) bool { + client := s.getClient(providerType) + if c, ok := client.(assignmentTyper); ok { + return c.IsExclusiveRoleAssignment(ctx) + } + return false +} + func (s *Service) getResources(ctx context.Context, p *domain.Provider) ([]*domain.Resource, error) { c := s.getClient(p.Type) if c == nil { @@ -579,7 +601,7 @@ func (s *Service) getResources(ctx context.Context, p *domain.Provider) ([]*doma existingProviderResources := map[string]bool{} for _, r := range flattenedProviderResources { for _, er := range existingGuardianResources { - if er.URN == r.URN { + if er.Type == r.Type && er.URN == r.URN { if existingDetails := er.Details; existingDetails != nil { if r.Details != nil { for key, value := range existingDetails { diff --git a/core/provider/service_test.go b/core/provider/service_test.go index 31869091e..148c55317 100644 --- a/core/provider/service_test.go +++ b/core/provider/service_test.go @@ -364,7 +364,7 @@ func (s *ServiceTestSuite) TestFetchResources() { for _, p := range providers { s.mockProvider.On("GetResources", mockCtx, p.Config).Return([]*domain.Resource{}, nil).Once() } - expectedError := errors.New("failed to add resources providers - [mock_provider]") + expectedError := errors.New("failed to add resources for providers: [mock_provider]") s.mockResourceService.On("BulkUpsert", mock.Anything, mock.Anything).Return(expectedError).Once() s.mockResourceService.On("Find", mock.Anything, mock.Anything).Return([]*domain.Resource{}, nil).Once() actualError := s.service.FetchResources(context.Background()) diff --git a/domain/provider.go b/domain/provider.go index b2d7f6312..51799b05f 100644 --- a/domain/provider.go +++ b/domain/provider.go @@ -7,24 +7,16 @@ import ( ) const ( - // ProviderTypeBigQuery is the type name for BigQuery provider - ProviderTypeBigQuery = "bigquery" - // ProviderTypeMetabase is the type name for Metabase provider - ProviderTypeMetabase = "metabase" - // ProviderTypeGrafana is the type name for Grafana provider - ProviderTypeGrafana = "grafana" - // ProviderTypeTableau is the type name for Tableau provider - ProviderTypeTableau = "tableau" - // ProviderTypeGCloudIAM is the type name for Google Cloud IAM provider + ProviderTypeBigQuery = "bigquery" + ProviderTypeMetabase = "metabase" + ProviderTypeGrafana = "grafana" + ProviderTypeTableau = "tableau" ProviderTypeGCloudIAM = "gcloud_iam" - // ProviderTypeNoOp is the type name for No-Op provider - ProviderTypeNoOp = "noop" - // ProviderTypeGCS is the type name for Google Cloud Storage provider - ProviderTypeGCS = "gcs" - // ProviderTypePolicyTag is the type name for Dataplex + ProviderTypeNoOp = "noop" + ProviderTypeGCS = "gcs" ProviderTypePolicyTag = "dataplex" - // ProviderTypeShield is the type name for Shield auth layer provider - ProviderTypeShield = "shield" + ProviderTypeShield = "shield" + ProviderTypeGitlab = "gitlab" ) // Role is the configuration to define a role and mapping the permissions in the provider @@ -64,8 +56,6 @@ type AppealConfig struct { AllowPermanentAccess bool `json:"allow_permanent_access" yaml:"allow_permanent_access"` AllowActiveAccessExtensionIn string `json:"allow_active_access_extension_in" yaml:"allow_active_access_extension_in" validate:"required"` } - -// ProviderConfig is the configuration for a data provider type ProviderConfig struct { Type string `json:"type" yaml:"type" validate:"required,oneof=google_bigquery metabase grafana tableau gcloud_iam noop gcs"` URN string `json:"urn" yaml:"urn" validate:"required"` @@ -101,7 +91,6 @@ func (pc ProviderConfig) GetFilterForResourceType(resourceType string) string { return "" } -// Provider domain structure type Provider struct { ID string `json:"id" yaml:"id"` Type string `json:"type" yaml:"type"` diff --git a/go.mod b/go.mod index 60d08e9d4..4553b2864 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 github.com/imdario/mergo v0.3.12 + github.com/jackc/pgx/v5 v5.3.0 github.com/lib/pq v1.10.0 github.com/mcuadros/go-defaults v1.2.0 github.com/mcuadros/go-lookup v0.0.0-20200831155250-80f87a4fa5ee @@ -30,19 +31,20 @@ require ( github.com/spf13/cobra v1.2.1 github.com/stretchr/testify v1.8.4 github.com/uptrace/opentelemetry-go-extra/otelgorm v0.1.17 + github.com/xanzy/go-gitlab v0.98.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.28.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.37.0 go.opentelemetry.io/otel v1.11.2 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.3.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.11.2 go.opentelemetry.io/otel/sdk v1.11.2 - golang.org/x/net v0.6.0 - golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 + golang.org/x/net v0.8.0 + golang.org/x/oauth2 v0.6.0 golang.org/x/sync v0.1.0 google.golang.org/api v0.106.0 google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f google.golang.org/grpc v1.51.0 - google.golang.org/protobuf v1.28.1 + google.golang.org/protobuf v1.29.1 gopkg.in/yaml.v3 v3.0.1 gorm.io/datatypes v1.0.0 gorm.io/driver/postgres v1.4.7 @@ -80,20 +82,22 @@ require ( github.com/go-playground/universal-translator v0.17.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.1 // indirect github.com/googleapis/gax-go/v2 v2.7.0 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.2 // indirect github.com/hashicorp/go-version v1.3.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.3.0 // indirect github.com/jeremywohl/flatten v1.0.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -143,9 +147,10 @@ require ( go.uber.org/multierr v1.7.0 // indirect go.uber.org/zap v1.19.0 // indirect golang.org/x/crypto v0.6.0 // indirect - golang.org/x/sys v0.5.0 // indirect - golang.org/x/term v0.5.0 // indirect - golang.org/x/text v0.7.0 // indirect + golang.org/x/sys v0.6.0 // indirect + golang.org/x/term v0.6.0 // indirect + golang.org/x/text v0.8.0 // indirect + golang.org/x/time v0.3.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/ini.v1 v1.62.0 // indirect diff --git a/go.sum b/go.sum index 18817a284..1d127ed95 100644 --- a/go.sum +++ b/go.sum @@ -633,8 +633,9 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= @@ -662,6 +663,7 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-containerregistry v0.5.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0= github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -749,12 +751,18 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0= +github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= @@ -1294,6 +1302,8 @@ github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1 github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI= github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= +github.com/xanzy/go-gitlab v0.98.0 h1:psTMbnA0vSo512M8WUpM5YIFPxrdQ/11V0y/5SdzIIg= +github.com/xanzy/go-gitlab v0.98.0/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= @@ -1560,8 +1570,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.0.0-20220812174116-3211cb980234/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20220919232410-f2f64ebce3c1/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= -golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1587,8 +1597,8 @@ golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= -golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 h1:nt+Q6cXKz4MosCSpnbMtqiQ8Oz0pxTef2B4Vca2lvfk= -golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1745,8 +1755,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220818161305-2296e01440c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1754,8 +1764,8 @@ golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4 golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= -golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1767,8 +1777,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1778,6 +1788,8 @@ golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -2081,8 +2093,9 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.29.1 h1:7QBf+IK2gx70Ap/hDsOmam3GE0v9HicjfEdAxE62UoM= +google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/server/services.go b/internal/server/services.go index 222c348dd..98ed6f7df 100644 --- a/internal/server/services.go +++ b/internal/server/services.go @@ -4,6 +4,7 @@ import ( "context" "github.com/goto/guardian/plugins/providers/dataplex" + "github.com/goto/guardian/plugins/providers/gitlab" "github.com/go-playground/validator/v10" "github.com/google/uuid" @@ -112,6 +113,7 @@ func InitServices(deps ServiceDeps) (*Services, error) { gcs.NewProvider(domain.ProviderTypeGCS, deps.Crypto), dataplex.NewProvider(domain.ProviderTypePolicyTag, deps.Crypto), shield.NewProvider(domain.ProviderTypeShield, deps.Logger), + gitlab.NewProvider(domain.ProviderTypeGitlab, deps.Crypto, deps.Logger), } iamManager := identities.NewManager(deps.Crypto, deps.Validator) diff --git a/plugins/providers/gitlab/config.go b/plugins/providers/gitlab/config.go new file mode 100644 index 000000000..75575b642 --- /dev/null +++ b/plugins/providers/gitlab/config.go @@ -0,0 +1,137 @@ +package gitlab + +import ( + "errors" + "fmt" + + "github.com/goto/guardian/domain" + "github.com/goto/guardian/utils" + "github.com/mitchellh/mapstructure" + "github.com/xanzy/go-gitlab" +) + +const ( + accountTypeGitlabUserID = "gitlab_user_id" + + resourceTypeProject = "project" + resourceTypeGroup = "group" + + // https://docs.gitlab.com/ee/api/members.html#roles + roleNoAccess = "no_access" + roleMinimalAccess = "minimal_access" + roleGuest = "guest" + roleReporter = "reporter" + roleDeveloper = "developer" + roleMaintainer = "maintainer" + roleOwner = "owner" +) + +var ( + validResourceTypes = []string{resourceTypeProject, resourceTypeGroup} + validGitlabRoles = []string{ + roleNoAccess, + roleMinimalAccess, + roleGuest, + roleReporter, + roleDeveloper, + roleMaintainer, + roleOwner, + } + gitlabRoleMapping = map[string]gitlab.AccessLevelValue{ + roleNoAccess: gitlab.NoPermissions, + roleMinimalAccess: gitlab.AccessLevelValue(5), + roleGuest: gitlab.GuestPermissions, + roleReporter: gitlab.ReporterPermissions, + roleDeveloper: gitlab.DeveloperPermissions, + roleMaintainer: gitlab.MaintainerPermissions, + roleOwner: gitlab.OwnerPermissions, + } +) + +type credentials struct { + Host string `mapstructure:"host" yaml:"host" json:"host"` + AccessToken string `mapstructure:"access_token" yaml:"access_token" json:"access_token"` +} + +func (c credentials) validate() error { + if c.Host == "" { + return errors.New("host is required") + } + if c.AccessToken == "" { + return errors.New("access_token is required") + } + return nil +} + +func (c *credentials) encrypt(encryptor domain.Encryptor) error { + encryptedAccessToken, err := encryptor.Encrypt(c.AccessToken) + if err != nil { + return err + } + + c.AccessToken = encryptedAccessToken + return nil +} + +func (c *credentials) decrypt(decryptor domain.Decryptor) error { + decryptedAccessToken, err := decryptor.Decrypt(c.AccessToken) + if err != nil { + return err + } + + c.AccessToken = decryptedAccessToken + return nil +} + +type config struct { + *domain.ProviderConfig +} + +func (c *config) getCredentials() (*credentials, error) { + if creds, ok := c.Credentials.(credentials); ok { // parsed + return &creds, nil + } else if mapCreds, ok := c.Credentials.(map[string]interface{}); ok { // not parsed + var creds credentials + if err := mapstructure.Decode(mapCreds, &creds); err != nil { + return nil, fmt.Errorf("unable to decode credentials: %w", err) + } + return &creds, nil + } + + return nil, fmt.Errorf("invalid credentials type: %T", c.Credentials) +} + +func (c *config) validateGitlabSpecificConfig() error { + // validate credentials + if c.Credentials == nil { + return fmt.Errorf("missing credentials") + } + creds, err := c.getCredentials() + if err != nil { + return err + } + if err := creds.validate(); err != nil { + return fmt.Errorf("invalid credentials: %w", err) + } + + // validate resource config + for _, rc := range c.Resources { + if !utils.ContainsString(validResourceTypes, rc.Type) { + return fmt.Errorf("invalid resource type: %q", rc.Type) + } + + for _, role := range rc.Roles { + for _, permission := range role.Permissions { + permissionString, ok := permission.(string) + if !ok { + return fmt.Errorf("unexpected permission type: %T, expected: string", permission) + } + if !utils.ContainsString(validGitlabRoles, permissionString) { + return fmt.Errorf("invalid permission: %q", permissionString) + } + } + } + } + + return nil +} diff --git a/plugins/providers/gitlab/helper.go b/plugins/providers/gitlab/helper.go new file mode 100644 index 000000000..2186b27cb --- /dev/null +++ b/plugins/providers/gitlab/helper.go @@ -0,0 +1,62 @@ +package gitlab + +import ( + "context" + + "github.com/goto/guardian/domain" + "github.com/xanzy/go-gitlab" +) + +type pageFetcher[T any] func(gitlab.ListOptions, ...gitlab.RequestOptionFunc) ([]T, *gitlab.Response, error) + +type mapper[T, T2 any] func(T) T2 + +func fetchAllPages[T any]( + ctx context.Context, + fetchPage pageFetcher[T], +) ([]T, error) { + var records []T + + opt := gitlab.ListOptions{ + PerPage: 100, + Pagination: "keyset", + OrderBy: "id", + Sort: "asc", + } + options := []gitlab.RequestOptionFunc{gitlab.WithContext(ctx)} + for { + pageRecords, resp, err := fetchPage(opt, options...) + if err != nil { + return nil, err + } + records = append(records, pageRecords...) + + if resp.NextLink == "" { + break + } + opt.Page = resp.NextPage + options = []gitlab.RequestOptionFunc{ + gitlab.WithContext(ctx), + gitlab.WithKeysetPaginationParameters(resp.NextLink), + } + } + + return records, nil +} + +func fetchResources[T *gitlab.Group | *gitlab.Project]( + ctx context.Context, + fetchFunc pageFetcher[T], + mapFunc mapper[T, *domain.Resource], +) ([]*domain.Resource, error) { + records, err := fetchAllPages(ctx, fetchFunc) + if err != nil { + return nil, err + } + + mappedRecords := make([]*domain.Resource, len(records)) + for i, r := range records { + mappedRecords[i] = mapFunc(r) + } + return mappedRecords, nil +} diff --git a/plugins/providers/gitlab/mocks/encryptor.go b/plugins/providers/gitlab/mocks/encryptor.go new file mode 100644 index 000000000..b7fb0f51a --- /dev/null +++ b/plugins/providers/gitlab/mocks/encryptor.go @@ -0,0 +1,136 @@ +// Code generated by mockery v2.33.0. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// Encryptor is an autogenerated mock type for the encryptor type +type Encryptor struct { + mock.Mock +} + +type Encryptor_Expecter struct { + mock *mock.Mock +} + +func (_m *Encryptor) EXPECT() *Encryptor_Expecter { + return &Encryptor_Expecter{mock: &_m.Mock} +} + +// Decrypt provides a mock function with given fields: _a0 +func (_m *Encryptor) 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) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Encryptor_Decrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Decrypt' +type Encryptor_Decrypt_Call struct { + *mock.Call +} + +// Decrypt is a helper method to define mock.On call +// - _a0 string +func (_e *Encryptor_Expecter) Decrypt(_a0 interface{}) *Encryptor_Decrypt_Call { + return &Encryptor_Decrypt_Call{Call: _e.mock.On("Decrypt", _a0)} +} + +func (_c *Encryptor_Decrypt_Call) Run(run func(_a0 string)) *Encryptor_Decrypt_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Encryptor_Decrypt_Call) Return(_a0 string, _a1 error) *Encryptor_Decrypt_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Encryptor_Decrypt_Call) RunAndReturn(run func(string) (string, error)) *Encryptor_Decrypt_Call { + _c.Call.Return(run) + return _c +} + +// Encrypt provides a mock function with given fields: _a0 +func (_m *Encryptor) 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) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Encryptor_Encrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encrypt' +type Encryptor_Encrypt_Call struct { + *mock.Call +} + +// Encrypt is a helper method to define mock.On call +// - _a0 string +func (_e *Encryptor_Expecter) Encrypt(_a0 interface{}) *Encryptor_Encrypt_Call { + return &Encryptor_Encrypt_Call{Call: _e.mock.On("Encrypt", _a0)} +} + +func (_c *Encryptor_Encrypt_Call) Run(run func(_a0 string)) *Encryptor_Encrypt_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Encryptor_Encrypt_Call) Return(_a0 string, _a1 error) *Encryptor_Encrypt_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Encryptor_Encrypt_Call) RunAndReturn(run func(string) (string, error)) *Encryptor_Encrypt_Call { + _c.Call.Return(run) + return _c +} + +// NewEncryptor creates a new instance of Encryptor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewEncryptor(t interface { + mock.TestingT + Cleanup(func()) +}) *Encryptor { + mock := &Encryptor{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/plugins/providers/gitlab/provider.go b/plugins/providers/gitlab/provider.go new file mode 100644 index 000000000..d077eae64 --- /dev/null +++ b/plugins/providers/gitlab/provider.go @@ -0,0 +1,279 @@ +package gitlab + +import ( + "context" + "fmt" + "net/http" + "strconv" + "sync" + + pv "github.com/goto/guardian/core/provider" + "github.com/goto/guardian/domain" + "github.com/goto/guardian/pkg/log" + "github.com/goto/guardian/utils" + "github.com/xanzy/go-gitlab" + "golang.org/x/sync/errgroup" +) + +//go:generate mockery --name=encryptor --exported --with-expecter +type encryptor interface { + domain.Crypto +} + +type provider struct { + pv.UnimplementedClient + pv.PermissionManager + + typeName string + encryptor encryptor + logger log.Logger + + clients map[string]*gitlab.Client + mu sync.Mutex +} + +func NewProvider(typeName string, encryptor encryptor, logger log.Logger) *provider { + return &provider{ + typeName: typeName, + encryptor: encryptor, + logger: logger, + + clients: map[string]*gitlab.Client{}, + } +} + +func (p *provider) GetType() string { + return p.typeName +} + +func (p *provider) CreateConfig(pc *domain.ProviderConfig) error { + cfg := &config{pc} + if err := cfg.validateGitlabSpecificConfig(); err != nil { + return fmt.Errorf("invalid gitlab config: %w", err) + } + + // encrypt sensitive config + creds, err := cfg.getCredentials() + if err != nil { + return err + } + if err := creds.encrypt(p.encryptor); err != nil { + return fmt.Errorf("unable to encrypt credentials: %w", err) + } + pc.Credentials = creds + + return nil +} + +func (p *provider) GetResources(ctx context.Context, pc *domain.ProviderConfig) ([]*domain.Resource, error) { + client, err := p.getClient(*pc) + if err != nil { + return nil, err + } + + eg, ctx := errgroup.WithContext(ctx) + eg.SetLimit(20) + + var mu sync.Mutex + var resources []*domain.Resource + resourceTypes := pc.GetResourceTypes() + + groups, err := fetchResources(ctx, + func(listOpt gitlab.ListOptions, reqOpts ...gitlab.RequestOptionFunc) ([]*gitlab.Group, *gitlab.Response, error) { + return client.Groups.ListGroups(&gitlab.ListGroupsOptions{ListOptions: listOpt}, reqOpts...) + }, + func(g *gitlab.Group) *domain.Resource { + r := group{*g, pc.Type, pc.URN}.toResource() + return &r + }, + ) + if err != nil { + p.logger.Error(ctx, "unable to fetch groups", "provider_urn", pc.URN, "error", err) + return nil, fmt.Errorf("unable to fetch groups: %w", err) + } + + for _, group := range groups { + group := group + eg.Go(func() error { + if utils.ContainsString(resourceTypes, resourceTypeProject) { + projects, err := fetchResources(ctx, + func(listOpt gitlab.ListOptions, reqOpts ...gitlab.RequestOptionFunc) ([]*gitlab.Project, *gitlab.Response, error) { + falseBool := false + return client.Groups.ListGroupProjects( + group.URN, + &gitlab.ListGroupProjectsOptions{ + ListOptions: listOpt, + WithShared: &falseBool, + }, + reqOpts..., + ) + }, + func(p *gitlab.Project) *domain.Resource { + r := project{*p, pc.Type, pc.URN}.toResource() + return &r + }, + ) + if err != nil { + p.logger.Error(ctx, "unable to fetch projects under a group", "provider_urn", pc.URN, "group_id", group.URN, "error", err) + return fmt.Errorf("unable to fetch projects under group %q: %w", group.URN, err) + } + + if utils.ContainsString(resourceTypes, resourceTypeGroup) { + group.Children = projects + } else { + mu.Lock() + resources = append(resources, projects...) + mu.Unlock() + } + } + + // TODO: handle group <> sub-groups hierarchy + if utils.ContainsString(resourceTypes, resourceTypeGroup) { + mu.Lock() + resources = append(resources, group) + mu.Unlock() + } + return nil + }) + } + if err := eg.Wait(); err != nil { + return nil, err + } + + return resources, nil +} + +func (p *provider) GrantAccess(ctx context.Context, pc *domain.ProviderConfig, g domain.Grant) error { + client, err := p.getClient(*pc) + if err != nil { + return err + } + + userID, err := strconv.Atoi(g.AccountID) + if err != nil { + return fmt.Errorf("invalid user ID: %q: %w", g.AccountID, err) + } + + if len(g.Permissions) != 1 { + return fmt.Errorf("unexpected number of permissions: %d", len(g.Permissions)) + } + accessLevel, ok := gitlabRoleMapping[g.Permissions[0]] + if !ok { + return fmt.Errorf("invalid grant permission: %q", g.Permissions[0]) + } + + switch g.Resource.Type { + case resourceTypeGroup: + _, res, err := client.GroupMembers.AddGroupMember(g.Resource.URN, &gitlab.AddGroupMemberOptions{ + UserID: &userID, + AccessLevel: &accessLevel, + }, gitlab.WithContext(ctx)) + if res != nil && res.StatusCode == http.StatusConflict { + _, _, err = client.GroupMembers.EditGroupMember(g.Resource.URN, userID, &gitlab.EditGroupMemberOptions{ + AccessLevel: &accessLevel, + }) + } + if err != nil { + return err + } + case resourceTypeProject: + _, res, err := client.ProjectMembers.AddProjectMember(g.Resource.URN, &gitlab.AddProjectMemberOptions{ + UserID: &userID, + AccessLevel: &accessLevel, + }, gitlab.WithContext(ctx)) + if res != nil && res.StatusCode == http.StatusConflict { + _, _, err = client.ProjectMembers.EditProjectMember(g.Resource.URN, userID, &gitlab.EditProjectMemberOptions{ + AccessLevel: &accessLevel, + }) + } + if err != nil { + return err + } + default: + return fmt.Errorf("invalid resource type: %q", g.Resource.Type) + } + + return nil +} + +func (p *provider) RevokeAccess(ctx context.Context, pc *domain.ProviderConfig, g domain.Grant) error { + client, err := p.getClient(*pc) + if err != nil { + return err + } + + userID, err := strconv.Atoi(g.AccountID) + if err != nil { + return fmt.Errorf("invalid user ID: %q: %w", g.AccountID, err) + } + + if len(g.Permissions) != 1 { + return fmt.Errorf("unexpected number of permissions: %d", len(g.Permissions)) + } + accessLevel, ok := gitlabRoleMapping[g.Permissions[0]] + if !ok { + return fmt.Errorf("invalid grant permission: %q", g.Permissions[0]) + } + + var res *gitlab.Response + switch g.Resource.Type { + case resourceTypeGroup: + var member *gitlab.GroupMember + member, res, err = client.GroupMembers.GetGroupMember(g.Resource.URN, userID, gitlab.WithContext(ctx)) + if member != nil && member.AccessLevel == accessLevel { + res, err = client.GroupMembers.RemoveGroupMember(g.Resource.URN, userID, &gitlab.RemoveGroupMemberOptions{}, gitlab.WithContext(ctx)) + } + case resourceTypeProject: + var member *gitlab.ProjectMember + member, res, err = client.ProjectMembers.GetProjectMember(g.Resource.URN, userID, gitlab.WithContext(ctx)) + if member != nil && member.AccessLevel == accessLevel { + res, err = client.ProjectMembers.DeleteProjectMember(g.Resource.URN, userID, gitlab.WithContext(ctx)) + } + default: + return fmt.Errorf("invalid resource type: %q", g.Resource.Type) + } + if res != nil && res.StatusCode == http.StatusNotFound { + return nil + } else if err != nil { + return fmt.Errorf("unable to revoke access: %w", err) + } + + return nil +} + +func (p *provider) GetRoles(pc *domain.ProviderConfig, resourceType string) ([]*domain.Role, error) { + return pv.GetRoles(pc, resourceType) +} + +func (p *provider) GetAccountTypes() []string { + return []string{accountTypeGitlabUserID} +} + +func (p *provider) IsExclusiveRoleAssignment(context.Context) bool { + return true +} + +func (p *provider) getClient(pc domain.ProviderConfig) (*gitlab.Client, error) { + if client, ok := p.clients[pc.URN]; ok { + return client, nil + } + + cfg := &config{&pc} + creds, err := cfg.getCredentials() + if err != nil { + return nil, err + } + if err := creds.decrypt(p.encryptor); err != nil { + return nil, fmt.Errorf("unable to decrypt credentials: %w", err) + } + + client, err := gitlab.NewClient(creds.AccessToken, gitlab.WithBaseURL(creds.Host)) + if err != nil { + return nil, fmt.Errorf("unable to create gitlab client: %w", err) + } + + p.mu.Lock() + p.clients[pc.URN] = client + p.mu.Unlock() + return client, nil +} diff --git a/plugins/providers/gitlab/provider_test.go b/plugins/providers/gitlab/provider_test.go new file mode 100644 index 000000000..d72fa7bca --- /dev/null +++ b/plugins/providers/gitlab/provider_test.go @@ -0,0 +1,619 @@ +package gitlab_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/goto/guardian/domain" + "github.com/goto/guardian/pkg/log" + "github.com/goto/guardian/plugins/providers/gitlab" + "github.com/goto/guardian/plugins/providers/gitlab/mocks" + "github.com/mitchellh/mapstructure" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + groupsEndpoint = "/api/v4/groups" + groupProjectsEndpoint = func(gID string) string { return fmt.Sprintf("/api/v4/groups/%s/projects", gID) } + groupMembersEndpoint = func(gID string) string { return fmt.Sprintf("/api/v4/groups/%s/members", gID) } + groupMemberDetailsEndpoint = func(gID, uID string) string { return fmt.Sprintf("/api/v4/groups/%s/members/%s", gID, uID) } + + projectMembersEndpoint = func(pID string) string { return fmt.Sprintf("/api/v4/projects/%s/members", pID) } + projectMemberDetailsEndpoint = func(pID, uID string) string { return fmt.Sprintf("/api/v4/projects/%s/members/%s", pID, uID) } +) + +func TestGetType(t *testing.T) { + t.Run("should return set provider type", func(t *testing.T) { + expectedProviderType := "gitlab" + provider := gitlab.NewProvider(expectedProviderType, nil, log.NewNoop()) + assert.Equal(t, expectedProviderType, provider.GetType()) + }) +} + +func TestCreateConfig(t *testing.T) { + t.Run("should encrypt sensitive value(s) in provider config", func(t *testing.T) { + encryptor := new(mocks.Encryptor) + logger := log.NewNoop() + gitlabProvider := gitlab.NewProvider("gitlab", encryptor, logger) + + pc := &domain.ProviderConfig{ + Type: "gitlab", + URN: "test-gitlab", + Credentials: map[string]interface{}{ + "host": "https://gitlab.com", + "access_token": "test-token", + }, + Resources: []*domain.ResourceConfig{ + { + Type: "group", + Policy: &domain.PolicyConfig{ID: "test-policy", Version: 1}, + Roles: []*domain.Role{ + {ID: "test-developer-role", Permissions: []interface{}{"developer"}}, + }, + }, + }, + } + + expectedEncryptedToken := "encrypted-token" + encryptor.EXPECT().Encrypt("test-token").Return(expectedEncryptedToken, nil) + defer encryptor.AssertExpectations(t) + + err := gitlabProvider.CreateConfig(pc) + assert.NoError(t, err) + + // read encrypted token from credentials + var credsMap map[string]interface{} + err = mapstructure.Decode(pc.Credentials, &credsMap) + require.NoError(t, err) + + assert.Equal(t, expectedEncryptedToken, credsMap["access_token"]) + }) +} + +func TestGetResources(t *testing.T) { + t.Run("should return gitlab resources on success", func(t *testing.T) { + dummyGroupsBytes, err := readFixtures("testdata/groups/page_1.json") + require.NoError(t, err) + var dummyGroups []map[string]interface{} + err = json.Unmarshal(dummyGroupsBytes, &dummyGroups) + require.NoError(t, err) + + server := http.NewServeMux() + server.HandleFunc(groupsEndpoint, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.WriteHeader(http.StatusOK) + w.Write(dummyGroupsBytes) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }) + for _, g := range dummyGroups { + gID := fmt.Sprintf("%v", g["id"]) + server.HandleFunc(groupProjectsEndpoint(gID), func(w http.ResponseWriter, r *http.Request) { + withShared := r.URL.Query().Get("with_shared") + switch r.Method { + case http.MethodGet: + assert.Equal(t, "false", withShared) + if gID == "1" { + projects, err := readFixtures("testdata/projects/page_1.json") + require.NoError(t, err) + w.WriteHeader(http.StatusOK) + w.Write(projects) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("[]")) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }) + } + ts := httptest.NewServer(server) + defer ts.Close() + + encryptor := new(mocks.Encryptor) + logger := log.NewNoop() + gitlabProvider := gitlab.NewProvider("gitlab", encryptor, logger) + + encryptor.EXPECT().Decrypt("encrypted-token").Return("test-token", nil) + defer encryptor.AssertExpectations(t) + + pc := &domain.ProviderConfig{ + Type: "gitlab", + URN: "test-gitlab", + Credentials: map[string]interface{}{ + "host": ts.URL, + "access_token": "encrypted-token", + }, + Resources: []*domain.ResourceConfig{ + { + Type: "group", + Policy: &domain.PolicyConfig{ID: "test-policy", Version: 1}, + Roles: []*domain.Role{ + {ID: "test-developer-role", Permissions: []interface{}{"developer"}}, + }, + }, + { + Type: "project", + Policy: &domain.PolicyConfig{ID: "test-policy", Version: 1}, + Roles: []*domain.Role{ + {ID: "test-developer-role", Permissions: []interface{}{"developer"}}, + }, + }, + }, + } + resources, err := gitlabProvider.GetResources(context.Background(), pc) + + assert.NoError(t, err) + assert.NotEmpty(t, resources) + }) + + t.Run("pagination", func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc(groupsEndpoint, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + idAfterParam := r.URL.Query().Get("id_after") + dummyIDAfter := "999" + + var groups []byte + var err error + switch idAfterParam { + case dummyIDAfter: + groups, err = readFixtures("testdata/groups/page_2.json") + default: + groups, err = readFixtures("testdata/groups/page_1.json") + + q := r.URL.Query() + q.Add("id_after", dummyIDAfter) + r.URL.RawQuery = q.Encode() + nextPageURL := fmt.Sprintf("http://%s/%s", r.Host, r.URL.String()) + linkHeader := fmt.Sprintf(`<%s>; rel="next"`, nextPageURL) + w.Header().Set("Link", linkHeader) + } + require.NoError(t, err) + + w.WriteHeader(http.StatusOK) + w.Write(groups) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }) + ts := httptest.NewServer(mux) + defer ts.Close() + + encryptor := new(mocks.Encryptor) + logger := log.NewNoop() + gitlabProvider := gitlab.NewProvider("gitlab", encryptor, logger) + + encryptor.EXPECT().Decrypt("encrypted-token").Return("test-token", nil) + defer encryptor.AssertExpectations(t) + expectedResourcesLen := 10 // 5 groups * 2 pages + + pc := &domain.ProviderConfig{ + Type: "gitlab", + URN: "test-gitlab", + Credentials: map[string]interface{}{ + "host": ts.URL, + "access_token": "encrypted-token", + }, + Resources: []*domain.ResourceConfig{ + { + Type: "group", + Policy: &domain.PolicyConfig{ID: "test-policy", Version: 1}, + Roles: []*domain.Role{ + {ID: "test-developer-role", Permissions: []interface{}{"developer"}}, + }, + }, + }, + } + resources, err := gitlabProvider.GetResources(context.Background(), pc) + + assert.NoError(t, err) + assert.Len(t, resources, expectedResourcesLen) + }) +} + +func TestGrantAcccess(t *testing.T) { + t.Run("should grant access to gitlab resources on success", func(t *testing.T) { + testCases := []struct { + name string + grant domain.Grant + handlers map[string]http.HandlerFunc // map[endpoint]handler + }{ + { + name: "should add access to group for new member", + grant: domain.Grant{ + AccountID: "99", + Permissions: []string{"developer"}, + Resource: &domain.Resource{Type: "group", URN: "1"}, + }, + handlers: map[string]http.HandlerFunc{ + groupMembersEndpoint("1"): func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + w.WriteHeader(http.StatusCreated) + w.Write([]byte("{}")) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }, + }, + }, + { + name: "should edit access to group for existing member", + grant: domain.Grant{ + AccountID: "99", + Permissions: []string{"developer"}, + Resource: &domain.Resource{Type: "group", URN: "1"}, + }, + handlers: map[string]http.HandlerFunc{ + groupMembersEndpoint("1"): func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + w.WriteHeader(http.StatusConflict) + w.Write([]byte(`{"message": "Member already exists"}`)) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }, + groupMemberDetailsEndpoint("1", "99"): func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPut: + w.WriteHeader(http.StatusOK) + w.Write([]byte("{}")) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }, + }, + }, + { + name: "should add access to project for new member", + grant: domain.Grant{ + AccountID: "99", + Permissions: []string{"developer"}, + Resource: &domain.Resource{Type: "project", URN: "1"}, + }, + handlers: map[string]http.HandlerFunc{ + projectMembersEndpoint("1"): func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + w.WriteHeader(http.StatusCreated) + w.Write([]byte("{}")) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }, + }, + }, + { + name: "should edit access to group for existing member", + grant: domain.Grant{ + AccountID: "99", + Permissions: []string{"developer"}, + Resource: &domain.Resource{Type: "project", URN: "1"}, + }, + handlers: map[string]http.HandlerFunc{ + projectMembersEndpoint("1"): func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + w.WriteHeader(http.StatusConflict) + w.Write([]byte(`{"message": "Member already exists"}`)) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }, + projectMemberDetailsEndpoint("1", "99"): func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPut: + w.WriteHeader(http.StatusOK) + w.Write([]byte("{}")) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mux := http.NewServeMux() + for endpoint, handler := range tc.handlers { + mux.HandleFunc(endpoint, handler) + } + ts := httptest.NewServer(mux) + defer ts.Close() + + encryptor := new(mocks.Encryptor) + logger := log.NewNoop() + gitlabProvider := gitlab.NewProvider("gitlab", encryptor, logger) + + encryptor.EXPECT().Decrypt("encrypted-token").Return("test-token", nil) + defer encryptor.AssertExpectations(t) + + pc := &domain.ProviderConfig{ + Type: "gitlab", + URN: "test-gitlab", + Credentials: map[string]interface{}{ + "host": ts.URL, + "access_token": "encrypted-token", + }, + Resources: []*domain.ResourceConfig{ + { + Type: "group", + Policy: &domain.PolicyConfig{ID: "test-policy", Version: 1}, + Roles: []*domain.Role{ + {ID: "test-developer-role", Permissions: []interface{}{"developer"}}, + }, + }, + { + Type: "project", + Policy: &domain.PolicyConfig{ID: "test-policy", Version: 1}, + Roles: []*domain.Role{ + {ID: "test-developer-role", Permissions: []interface{}{"developer"}}, + }, + }, + }, + } + + err := gitlabProvider.GrantAccess(context.Background(), pc, tc.grant) + assert.NoError(t, err) + }) + } + }) +} + +func TestRevokeAccess(t *testing.T) { + t.Run("should revoke access to gitlab resources on success", func(t *testing.T) { + testCases := []struct { + name string + grant domain.Grant + handlers map[string]http.HandlerFunc // map[endpoint]handler + }{ + { + name: "should remove access to group for existing member", + grant: domain.Grant{ + AccountID: "99", + Permissions: []string{"developer"}, + Resource: &domain.Resource{Type: "group", URN: "1"}, + }, + handlers: map[string]http.HandlerFunc{ + groupMemberDetailsEndpoint("1", "99"): func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: // check if member exists + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "access_level": 30 + }`)) + return + case http.MethodDelete: // remove member + w.WriteHeader(http.StatusNoContent) + w.Write([]byte("")) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }, + }, + }, + { + name: "should not return error if member does not exist in group", + grant: domain.Grant{ + AccountID: "99", + Permissions: []string{"developer"}, + Resource: &domain.Resource{Type: "group", URN: "1"}, + }, + handlers: map[string]http.HandlerFunc{ + groupMemberDetailsEndpoint("1", "99"): func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: // check if member exists + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "access_level": 30 + }`)) + return + case http.MethodDelete: // remove member + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message": "404 Not found"}`)) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }, + }, + }, + { + name: "should remove access to project for existing member", + grant: domain.Grant{ + AccountID: "99", + Permissions: []string{"developer"}, + Resource: &domain.Resource{Type: "project", URN: "1"}, + }, + handlers: map[string]http.HandlerFunc{ + projectMemberDetailsEndpoint("1", "99"): func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: // check if member exists + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "access_level": 30 + }`)) + return + case http.MethodDelete: // remove member + w.WriteHeader(http.StatusNoContent) + w.Write([]byte("")) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }, + }, + }, + { + name: "should not return error if member does not exist in project", + grant: domain.Grant{ + AccountID: "99", + Permissions: []string{"developer"}, + Resource: &domain.Resource{Type: "project", URN: "1"}, + }, + handlers: map[string]http.HandlerFunc{ + projectMemberDetailsEndpoint("1", "99"): func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: // check if member exists + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "access_level": 30 + }`)) + return + case http.MethodDelete: // remove member + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message": "404 Not found"}`)) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write(nil) + } + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mux := http.NewServeMux() + for endpoint, handler := range tc.handlers { + mux.HandleFunc(endpoint, handler) + } + ts := httptest.NewServer(mux) + defer ts.Close() + + encryptor := new(mocks.Encryptor) + logger := log.NewNoop() + gitlabProvider := gitlab.NewProvider("gitlab", encryptor, logger) + + encryptor.EXPECT().Decrypt("encrypted-token").Return("test-token", nil) + defer encryptor.AssertExpectations(t) + + pc := &domain.ProviderConfig{ + Type: "gitlab", + URN: "test-gitlab", + Credentials: map[string]interface{}{ + "host": ts.URL, + "access_token": "encrypted-token", + }, + Resources: []*domain.ResourceConfig{ + { + Type: "group", + Policy: &domain.PolicyConfig{ID: "test-policy", Version: 1}, + Roles: []*domain.Role{ + {ID: "test-developer-role", Permissions: []interface{}{"developer"}}, + }, + }, + { + Type: "project", + Policy: &domain.PolicyConfig{ID: "test-policy", Version: 1}, + Roles: []*domain.Role{ + {ID: "test-developer-role", Permissions: []interface{}{"developer"}}, + }, + }, + }, + } + + err := gitlabProvider.RevokeAccess(context.Background(), pc, tc.grant) + assert.NoError(t, err) + }) + } + }) +} + +func TestGetRoles(t *testing.T) { + t.Run("should return registered provider roles", func(t *testing.T) { + groupRoles := []*domain.Role{ + {ID: "test-developer-role", Permissions: []interface{}{"developer"}}, + {ID: "test-maintainer-role", Permissions: []interface{}{"maintainer"}}, + } + projectRoles := []*domain.Role{ + {ID: "test-developer-role", Permissions: []interface{}{"developer"}}, + {ID: "test-maintainer-role", Permissions: []interface{}{"maintainer"}}, + {ID: "test-owner-role", Permissions: []interface{}{"owner"}}, + } + pc := &domain.ProviderConfig{ + Type: "gitlab", + URN: "test-gitlab", + Resources: []*domain.ResourceConfig{ + { + Type: "group", + Policy: &domain.PolicyConfig{ID: "test-policy", Version: 1}, + Roles: groupRoles, + }, + { + Type: "project", + Policy: &domain.PolicyConfig{ID: "test-policy", Version: 1}, + Roles: projectRoles, + }, + }, + } + + provider := gitlab.NewProvider("gitlab", nil, log.NewNoop()) + roles, err := provider.GetRoles(pc, "group") + assert.NoError(t, err) + assert.Equal(t, groupRoles, roles) + + roles, err = provider.GetRoles(pc, "project") + assert.NoError(t, err) + assert.Equal(t, projectRoles, roles) + }) +} + +func TestGetAccountTypes(t *testing.T) { + t.Run("should return account types", func(t *testing.T) { + provider := gitlab.NewProvider("gitlab", nil, log.NewNoop()) + accountTypes := provider.GetAccountTypes() + assert.Equal(t, []string{"gitlab_user_id"}, accountTypes) + }) +} + +func TestIsExclusiveRoleAssignment(t *testing.T) { + t.Run("should return exclusive role assignment state = true", func(t *testing.T) { + provider := gitlab.NewProvider("gitlab", nil, log.NewNoop()) + assert.True(t, provider.IsExclusiveRoleAssignment(context.Background())) + }) +} + +func readFixtures(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(f) +} diff --git a/plugins/providers/gitlab/resource.go b/plugins/providers/gitlab/resource.go new file mode 100644 index 000000000..866941727 --- /dev/null +++ b/plugins/providers/gitlab/resource.go @@ -0,0 +1,71 @@ +package gitlab + +import ( + "strconv" + + "github.com/goto/guardian/core/resource" + "github.com/goto/guardian/domain" + "github.com/goto/guardian/utils" + "github.com/xanzy/go-gitlab" +) + +type group struct { + gitlab.Group + providerType string + providerURN string +} + +func (g group) toResource() domain.Resource { + strID := strconv.Itoa(g.ID) + return domain.Resource{ + ProviderType: g.providerType, + ProviderURN: g.providerURN, + Type: resourceTypeGroup, + URN: strID, + GlobalURN: utils.GetGlobalURN("gitlab", g.providerURN, resourceTypeGroup, strID), + Name: g.FullName, + Details: map[string]interface{}{ + resource.ReservedDetailsKeyMetadata: map[string]interface{}{ + "description": g.Description, + "path": g.Path, + "full_path": g.FullPath, + "name": g.Name, + "full_name": g.FullName, + "web_url": g.WebURL, + "parent_id": g.ParentID, + "visibility": g.Visibility, + }, + }, + } +} + +type project struct { + gitlab.Project + providerType string + providerURN string +} + +func (p project) toResource() domain.Resource { + strID := strconv.Itoa(p.ID) + return domain.Resource{ + ProviderType: p.providerType, + ProviderURN: p.providerURN, + Type: resourceTypeProject, + URN: strID, + GlobalURN: utils.GetGlobalURN("gitlab", p.providerURN, resourceTypeProject, strID), + Name: p.NameWithNamespace, + Details: map[string]interface{}{ + resource.ReservedDetailsKeyMetadata: map[string]interface{}{ + "description": p.Description, + "path": p.Path, + "path_with_namespace": p.PathWithNamespace, + "name": p.Name, + "name_with_namespace": p.NameWithNamespace, + "web_url": p.WebURL, + "visibility": p.Visibility, + "archived": p.Archived, + "namespace_id": p.Namespace.ID, + }, + }, + } +} diff --git a/plugins/providers/gitlab/testdata/groups/page_1.json b/plugins/providers/gitlab/testdata/groups/page_1.json new file mode 100644 index 000000000..b024d9671 --- /dev/null +++ b/plugins/providers/gitlab/testdata/groups/page_1.json @@ -0,0 +1,142 @@ +[ + { + "id": 1, + "name": "Foobar Group", + "path": "foo-bar", + "description": "An interesting group", + "visibility": "public", + "share_with_group_lock": false, + "require_two_factor_authentication": false, + "two_factor_grace_period": 48, + "project_creation_level": "developer", + "auto_devops_enabled": null, + "subgroup_creation_level": "owner", + "emails_disabled": null, + "emails_enabled": null, + "mentions_disabled": null, + "lfs_enabled": true, + "default_branch_protection": 2, + "avatar_url": "https://gitlab.example.com/uploads/group/avatar/1/foo.jpg", + "web_url": "https://gitlab.example.com/groups/foo-bar", + "request_access_enabled": false, + "repository_storage": "default", + "full_name": "Foobar Group", + "full_path": "foo-bar", + "file_template_project_id": 1, + "parent_id": null, + "created_at": "2020-01-15T12:36:29.590Z", + "ip_restriction_ranges": null + }, + { + "id": 2, + "name": "Foobar Group 2", + "path": "foo-bar-2", + "description": "An interesting group", + "visibility": "public", + "share_with_group_lock": false, + "require_two_factor_authentication": false, + "two_factor_grace_period": 48, + "project_creation_level": "developer", + "auto_devops_enabled": null, + "subgroup_creation_level": "owner", + "emails_disabled": null, + "emails_enabled": null, + "mentions_disabled": null, + "lfs_enabled": true, + "default_branch_protection": 2, + "avatar_url": "https://gitlab.example.com/uploads/group/avatar/2/foo.jpg", + "web_url": "https://gitlab.example.com/groups/foo-bar", + "request_access_enabled": false, + "repository_storage": "default", + "full_name": "Foobar Group", + "full_path": "foo-bar", + "file_template_project_id": 1, + "parent_id": null, + "created_at": "2020-01-15T12:36:29.590Z", + "ip_restriction_ranges": null + }, + { + "id": 3, + "name": "Foobar Group 3", + "path": "foo-bar-3", + "description": "An interesting group", + "visibility": "public", + "share_with_group_lock": false, + "require_two_factor_authentication": false, + "two_factor_grace_period": 48, + "project_creation_level": "developer", + "auto_devops_enabled": null, + "subgroup_creation_level": "owner", + "emails_disabled": null, + "emails_enabled": null, + "mentions_disabled": null, + "lfs_enabled": true, + "default_branch_protection": 2, + "avatar_url": "https://gitlab.example.com/uploads/group/avatar/3/foo.jpg", + "web_url": "https://gitlab.example.com/groups/foo-bar", + "request_access_enabled": false, + "repository_storage": "default", + "full_name": "Foobar Group", + "full_path": "foo-bar", + "file_template_project_id": 1, + "parent_id": null, + "created_at": "2020-01-15T12:36:29.590Z", + "ip_restriction_ranges": null + }, + { + "id": 4, + "name": "Foobar Group 4", + "path": "foo-bar-4", + "description": "An interesting group", + "visibility": "public", + "share_with_group_lock": false, + "require_two_factor_authentication": false, + "two_factor_grace_period": 48, + "project_creation_level": "developer", + "auto_devops_enabled": null, + "subgroup_creation_level": "owner", + "emails_disabled": null, + "emails_enabled": null, + "mentions_disabled": null, + "lfs_enabled": true, + "default_branch_protection": 2, + "avatar_url": "https://gitlab.example.com/uploads/group/avatar/4/foo.jpg", + "web_url": "https://gitlab.example.com/groups/foo-bar", + "request_access_enabled": false, + "repository_storage": "default", + "full_name": "Foobar Group", + "full_path": "foo-bar", + "file_template_project_id": 1, + "parent_id": null, + "created_at": "2020-01-15T12:36:29.590Z", + "ip_restriction_ranges": null + }, + { + "id": 5, + "name": "Foobar Group 5", + "path": "foo-bar-5", + "description": "An interesting group", + "visibility": "public", + "share_with_group_lock": false, + "require_two_factor_authentication": false, + "two_factor_grace_period": 48, + "project_creation_level": "developer", + "auto_devops_enabled": null, + "subgroup_creation_level": "owner", + "emails_disabled": null, + "emails_enabled": null, + "mentions_disabled": null, + "lfs_enabled": true, + "default_branch_protection": 2, + "avatar_url": "https://gitlab.example.com/uploads/group/avatar/5/foo.jpg", + "web_url": "https://gitlab.example.com/groups/foo-bar", + "request_access_enabled": false, + "repository_storage": "default", + "full_name": "Foobar Group", + "full_path": "foo-bar", + "file_template_project_id": 1, + "parent_id": null, + "created_at": "2020-01-15T12:36:29.590Z", + "ip_restriction_ranges": null + } +] \ No newline at end of file diff --git a/plugins/providers/gitlab/testdata/groups/page_2.json b/plugins/providers/gitlab/testdata/groups/page_2.json new file mode 100644 index 000000000..4d76c5bce --- /dev/null +++ b/plugins/providers/gitlab/testdata/groups/page_2.json @@ -0,0 +1,142 @@ +[ + { + "id": 6, + "name": "Foobar Group 6", + "path": "foo-bar-6", + "description": "An interesting group", + "visibility": "public", + "share_with_group_lock": false, + "require_two_factor_authentication": false, + "two_factor_grace_period": 48, + "project_creation_level": "developer", + "auto_devops_enabled": null, + "subgroup_creation_level": "owner", + "emails_disabled": null, + "emails_enabled": null, + "mentions_disabled": null, + "lfs_enabled": true, + "default_branch_protection": 2, + "avatar_url": "https://gitlab.example.com/uploads/group/avatar/6/foo.jpg", + "web_url": "https://gitlab.example.com/groups/foo-bar", + "request_access_enabled": false, + "repository_storage": "default", + "full_name": "Foobar Group", + "full_path": "foo-bar", + "file_template_project_id": 1, + "parent_id": null, + "created_at": "2020-01-15T12:36:29.590Z", + "ip_restriction_ranges": null + }, + { + "id": 7, + "name": "Foobar Group 7", + "path": "foo-bar-7", + "description": "An interesting group", + "visibility": "public", + "share_with_group_lock": false, + "require_two_factor_authentication": false, + "two_factor_grace_period": 48, + "project_creation_level": "developer", + "auto_devops_enabled": null, + "subgroup_creation_level": "owner", + "emails_disabled": null, + "emails_enabled": null, + "mentions_disabled": null, + "lfs_enabled": true, + "default_branch_protection": 2, + "avatar_url": "https://gitlab.example.com/uploads/group/avatar/7/foo.jpg", + "web_url": "https://gitlab.example.com/groups/foo-bar", + "request_access_enabled": false, + "repository_storage": "default", + "full_name": "Foobar Group", + "full_path": "foo-bar", + "file_template_project_id": 1, + "parent_id": null, + "created_at": "2020-01-15T12:36:29.590Z", + "ip_restriction_ranges": null + }, + { + "id": 8, + "name": "Foobar Group 8", + "path": "foo-bar-8", + "description": "An interesting group", + "visibility": "public", + "share_with_group_lock": false, + "require_two_factor_authentication": false, + "two_factor_grace_period": 48, + "project_creation_level": "developer", + "auto_devops_enabled": null, + "subgroup_creation_level": "owner", + "emails_disabled": null, + "emails_enabled": null, + "mentions_disabled": null, + "lfs_enabled": true, + "default_branch_protection": 2, + "avatar_url": "https://gitlab.example.com/uploads/group/avatar/8/foo.jpg", + "web_url": "https://gitlab.example.com/groups/foo-bar", + "request_access_enabled": false, + "repository_storage": "default", + "full_name": "Foobar Group", + "full_path": "foo-bar", + "file_template_project_id": 1, + "parent_id": null, + "created_at": "2020-01-15T12:36:29.590Z", + "ip_restriction_ranges": null + }, + { + "id": 9, + "name": "Foobar Group 9", + "path": "foo-bar-9", + "description": "An interesting group", + "visibility": "public", + "share_with_group_lock": false, + "require_two_factor_authentication": false, + "two_factor_grace_period": 48, + "project_creation_level": "developer", + "auto_devops_enabled": null, + "subgroup_creation_level": "owner", + "emails_disabled": null, + "emails_enabled": null, + "mentions_disabled": null, + "lfs_enabled": true, + "default_branch_protection": 2, + "avatar_url": "https://gitlab.example.com/uploads/group/avatar/9/foo.jpg", + "web_url": "https://gitlab.example.com/groups/foo-bar", + "request_access_enabled": false, + "repository_storage": "default", + "full_name": "Foobar Group", + "full_path": "foo-bar", + "file_template_project_id": 1, + "parent_id": null, + "created_at": "2020-01-15T12:36:29.590Z", + "ip_restriction_ranges": null + }, + { + "id": 10, + "name": "Foobar Group 1", + "path": "foo-bar-1", + "description": "An interesting group", + "visibility": "public", + "share_with_group_lock": false, + "require_two_factor_authentication": false, + "two_factor_grace_period": 48, + "project_creation_level": "developer", + "auto_devops_enabled": null, + "subgroup_creation_level": "owner", + "emails_disabled": null, + "emails_enabled": null, + "mentions_disabled": null, + "lfs_enabled": true, + "default_branch_protection": 2, + "avatar_url": "https://gitlab.example.com/uploads/group/avatar/1/foo.jpg", + "web_url": "https://gitlab.example.com/groups/foo-bar", + "request_access_enabled": false, + "repository_storage": "default", + "full_name": "Foobar Group", + "full_path": "foo-bar", + "file_template_project_id": 1, + "parent_id": null, + "created_at": "2020-01-15T12:36:29.590Z", + "ip_restriction_ranges": null + } +] \ No newline at end of file diff --git a/plugins/providers/gitlab/testdata/projects/page_1.json b/plugins/providers/gitlab/testdata/projects/page_1.json new file mode 100644 index 000000000..9d15ecd3a --- /dev/null +++ b/plugins/providers/gitlab/testdata/projects/page_1.json @@ -0,0 +1,162 @@ +[ + { + "id": 1, + "description": null, + "name": "Test Project 1", + "name_with_namespace": "Namespace / Test Project 1", + "path": "test-project-1", + "path_with_namespace": "namespace/test-project-1", + "created_at": "2013-09-30T13:46:02Z", + "default_branch": "main", + "tag_list": [ + "example" + ], + "topics": [ + "example" + ], + "ssh_url_to_repo": "git@gitlab.example.com:namespace/test-project-1.git", + "http_url_to_repo": "https://gitlab.example.com/namespace/test-project-1.git", + "web_url": "https://gitlab.example.com/namespace/test-project-1", + "avatar_url": "https://gitlab.example.com/uploads/project/avatar/4/uploads/avatar.png", + "star_count": 0, + "last_activity_at": "2013-09-30T13:46:02Z", + "namespace": { + "id": 2, + "name": "Namespace", + "path": "namespace", + "kind": "group", + "full_path": "namespace", + "parent_id": null, + "avatar_url": null, + "web_url": "https://gitlab.example.com/namespace" + } + }, + { + "id": 2, + "description": null, + "name": "Test Project 2", + "name_with_namespace": "Namespace / Test Project 2", + "path": "test-project-2", + "path_with_namespace": "namespace/test-project-2", + "created_at": "2013-09-30T13:46:02Z", + "default_branch": "main", + "tag_list": [ + "example" + ], + "topics": [ + "example" + ], + "ssh_url_to_repo": "git@gitlab.example.com:namespace/test-project-2.git", + "http_url_to_repo": "https://gitlab.example.com/namespace/test-project-2.git", + "web_url": "https://gitlab.example.com/namespace/test-project-2", + "avatar_url": "https://gitlab.example.com/uploads/project/avatar/4/uploads/avatar.png", + "star_count": 0, + "last_activity_at": "2013-09-30T13:46:02Z", + "namespace": { + "id": 2, + "name": "Namespace", + "path": "namespace", + "kind": "group", + "full_path": "namespace", + "parent_id": null, + "avatar_url": null, + "web_url": "https://gitlab.example.com/namespace" + } + }, + { + "id": 3, + "description": null, + "name": "Test Project 3", + "name_with_namespace": "Namespace / Test Project 3", + "path": "test-project-3", + "path_with_namespace": "namespace/test-project-3", + "created_at": "2013-09-30T13:46:02Z", + "default_branch": "main", + "tag_list": [ + "example" + ], + "topics": [ + "example" + ], + "ssh_url_to_repo": "git@gitlab.example.com:namespace/test-project-3.git", + "http_url_to_repo": "https://gitlab.example.com/namespace/test-project-3.git", + "web_url": "https://gitlab.example.com/namespace/test-project-3", + "avatar_url": "https://gitlab.example.com/uploads/project/avatar/4/uploads/avatar.png", + "star_count": 0, + "last_activity_at": "2013-09-30T13:46:02Z", + "namespace": { + "id": 2, + "name": "Namespace", + "path": "namespace", + "kind": "group", + "full_path": "namespace", + "parent_id": null, + "avatar_url": null, + "web_url": "https://gitlab.example.com/namespace" + } + }, + { + "id": 4, + "description": null, + "name": "Test Project 4", + "name_with_namespace": "Namespace / Test Project 4", + "path": "test-project-4", + "path_with_namespace": "namespace/test-project-4", + "created_at": "2013-09-30T13:46:02Z", + "default_branch": "main", + "tag_list": [ + "example" + ], + "topics": [ + "example" + ], + "ssh_url_to_repo": "git@gitlab.example.com:namespace/test-project-4.git", + "http_url_to_repo": "https://gitlab.example.com/namespace/test-project-4.git", + "web_url": "https://gitlab.example.com/namespace/test-project-4", + "avatar_url": "https://gitlab.example.com/uploads/project/avatar/4/uploads/avatar.png", + "star_count": 0, + "last_activity_at": "2013-09-30T13:46:02Z", + "namespace": { + "id": 2, + "name": "Namespace", + "path": "namespace", + "kind": "group", + "full_path": "namespace", + "parent_id": null, + "avatar_url": null, + "web_url": "https://gitlab.example.com/namespace" + } + }, + { + "id": 5, + "description": null, + "name": "Test Project 5", + "name_with_namespace": "Namespace / Test Project 5", + "path": "test-project-5", + "path_with_namespace": "namespace/test-project-5", + "created_at": "2013-09-30T13:46:02Z", + "default_branch": "main", + "tag_list": [ + "example" + ], + "topics": [ + "example" + ], + "ssh_url_to_repo": "git@gitlab.example.com:namespace/test-project-5.git", + "http_url_to_repo": "https://gitlab.example.com/namespace/test-project-5.git", + "web_url": "https://gitlab.example.com/namespace/test-project-5", + "avatar_url": "https://gitlab.example.com/uploads/project/avatar/4/uploads/avatar.png", + "star_count": 0, + "last_activity_at": "2013-09-30T13:46:02Z", + "namespace": { + "id": 2, + "name": "Namespace", + "path": "namespace", + "kind": "group", + "full_path": "namespace", + "parent_id": null, + "avatar_url": null, + "web_url": "https://gitlab.example.com/namespace" + } + } +] \ No newline at end of file