diff --git a/cmd/worker/internal/authz/perms_syncer.go b/cmd/worker/internal/authz/perms_syncer.go index e459b58802d..6fe586b78e9 100644 --- a/cmd/worker/internal/authz/perms_syncer.go +++ b/cmd/worker/internal/authz/perms_syncer.go @@ -304,8 +304,8 @@ func (s *permsSyncerImpl) syncUserPerms(ctx context.Context, userID int32, noPer // Set sub-repository permissions. srp := s.db.SubRepoPerms() - for spec, perm := range results.subRepoPerms { - if err := srp.UpsertWithSpecWithIPs(ctx, user.ID, spec, *perm); err != nil { + for spec, u := range results.subRepoPerms { + if err := u.UpsertWithSpec(ctx, srp, userID, spec); err != nil { return result, providerStates, errors.Wrapf(err, "upserting sub repo perms %v for user %q (id: %d)", spec, user.Username, user.ID) } } @@ -365,11 +365,38 @@ type fetchUserPermsViaExternalAccountsResults struct { repoPerms map[int32][]int32 // A map from external repository spec to sub-repository permissions. This stores // the permissions for sub-repositories of private repositories. - subRepoPerms map[api.ExternalRepoSpec]*authz.SubRepoPermissionsWithIPs + subRepoPerms map[api.ExternalRepoSpec]subRepoPermissionsUpserter providerStates database.CodeHostStatusesSet } +type subRepoPermissionsUpserter interface { + // UpsertWithSpec inserts or updates the sub-repository permissions with the data + // stored in the upserter. + UpsertWithSpec(ctx context.Context, store database.SubRepoPermsStore, userID int32, spec api.ExternalRepoSpec) error +} + +type ipBasedPermissions struct { + perms *authz.SubRepoPermissionsWithIPs +} + +func (u *ipBasedPermissions) UpsertWithSpec(ctx context.Context, store database.SubRepoPermsStore, userID int32, spec api.ExternalRepoSpec) error { + return store.UpsertWithSpecWithIPs(ctx, userID, spec, *u.perms) +} + +type pathBasedPermissions struct { + perms *authz.SubRepoPermissions +} + +func (u *pathBasedPermissions) UpsertWithSpec(ctx context.Context, store database.SubRepoPermsStore, userID int32, spec api.ExternalRepoSpec) error { + return store.UpsertWithSpec(ctx, userID, spec, *u.perms) +} + +var ( + _ subRepoPermissionsUpserter = &ipBasedPermissions{} + _ subRepoPermissionsUpserter = &pathBasedPermissions{} +) + // fetchUserPermsViaExternalAccounts uses external accounts (aka. login // connections) to list all accessible private repositories on code hosts for // the given user. @@ -466,7 +493,7 @@ func (s *permsSyncerImpl) fetchUserPermsViaExternalAccounts(ctx context.Context, accts = append(accts, acct) } - results.subRepoPerms = make(map[api.ExternalRepoSpec]*authz.SubRepoPermissionsWithIPs) + results.subRepoPerms = make(map[api.ExternalRepoSpec]subRepoPermissionsUpserter) results.repoPerms = make(map[int32][]int32, len(accts)) for _, acct := range accts { @@ -482,6 +509,8 @@ func (s *permsSyncerImpl) fetchUserPermsViaExternalAccounts(ctx context.Context, acctLogger.Debug("update GitHub App installation access", log.Int32("accountID", acct.ID)) + subRepoPermsMap := make(map[extsvc.RepoID]subRepoPermissionsUpserter) + // FetchUserPerms makes API requests using a client that will deal with the token // expiration and try to refresh it when necessary. If the client fails to update // the token, or if the token is revoked, the "401 Unauthorized" error will be @@ -532,19 +561,33 @@ func (s *permsSyncerImpl) fetchUserPermsViaExternalAccounts(ctx context.Context, extPerms = new(authz.ExternalUserPermissions) // Load last synced sub-repo perms for this user and provider. - // Note: - // When fetching the xisting sub-repo perms, we can't afford to backfill any IP entries with "*". - // Since this data is later saved, these entries with NULL IPs will get saved - // as explicit wildcard entries in the database (which isn't the same and might result in information leakage). - // @ggilmore Thinks that we can't trade correctness for availability here, but I'm open to suggestions. + // First, try fetching the existing sub-repo perms with IPs. currentSubRepoPerms, err := s.db.SubRepoPerms().GetByUserAndServiceWithIPs(ctx, user.ID, provider.ServiceType(), provider.ServiceID(), false) if err != nil { - return results, errors.Wrap(err, "fetching existing sub-repo permissions") - } - extPerms.SubRepoPermissions = make(map[extsvc.RepoID]*authz.SubRepoPermissionsWithIPs, len(currentSubRepoPerms)) - for k := range currentSubRepoPerms { - v := currentSubRepoPerms[k] - extPerms.SubRepoPermissions[extsvc.RepoID(k.ID)] = &v + if !errors.Is(err, database.IPsNotSyncedError) { + return results, errors.Wrap(err, "fetching existing sub-repo permissions with IPs") + } + + // If we get here, this means that the current sub-repo permissions haven't + // updated with explicit IP addresses yet. + // + // So, instead we need to fetch the existing sub-repo perms without IPs, and wrap them in + // the pathBasedPermissions Upserter so that we can save them to the database (and avoid accidentally + // save them with "fake" IP addresses and not be able to differentiate that later) + unconvertedSubRepoPerms, err := s.db.SubRepoPerms().GetByUserAndService(ctx, user.ID, provider.ServiceType(), provider.ServiceID()) + if err != nil { + return results, errors.Wrap(err, "fetching existing sub-repo permissions") + } + + for spec, perm := range unconvertedSubRepoPerms { + subRepoPermsMap[extsvc.RepoID(spec.ID)] = &pathBasedPermissions{perms: &perm} + } + + } else { + // We have existing sub-repo permissions with associated IPs, so add them to the map + for spec, perm := range currentSubRepoPerms { + subRepoPermsMap[extsvc.RepoID(spec.ID)] = &ipBasedPermissions{perms: &perm} + } } // Load last synced repos for this user and account from user_repo_permissions table. @@ -564,6 +607,15 @@ func (s *permsSyncerImpl) fetchUserPermsViaExternalAccounts(ctx context.Context, } acctLogger.Warn("proceedWithPartialResults", log.Error(err)) } else { + // Happy path: we were able to fetch the permissions + if extPerms != nil { + for k, v := range extPerms.SubRepoPermissions { + subRepoPermsMap[k] = &ipBasedPermissions{ + perms: v, + } + } + } + err = accounts.TouchLastValid(ctx, acct.ID) if err != nil { return results, errors.Wrapf(err, "set last valid for external account %d", acct.ID) @@ -591,7 +643,7 @@ func (s *permsSyncerImpl) fetchUserPermsViaExternalAccounts(ctx context.Context, } // Record any sub-repository permissions. - for repoID := range extPerms.SubRepoPermissions { + for repoID := range subRepoPermsMap { spec := api.ExternalRepoSpec{ // This is safe since repoID is an extsvc.RepoID which represents the external id // of the repo. @@ -599,7 +651,8 @@ func (s *permsSyncerImpl) fetchUserPermsViaExternalAccounts(ctx context.Context, ServiceType: provider.ServiceType(), ServiceID: provider.ServiceID(), } - results.subRepoPerms[spec] = extPerms.SubRepoPermissions[repoID] + + results.subRepoPerms[spec] = subRepoPermsMap[repoID] } for _, includePrefix := range extPerms.IncludeContains { diff --git a/cmd/worker/internal/authz/perms_syncer_test.go b/cmd/worker/internal/authz/perms_syncer_test.go index a24fdeec602..b26fe120a55 100644 --- a/cmd/worker/internal/authz/perms_syncer_test.go +++ b/cmd/worker/internal/authz/perms_syncer_test.go @@ -391,94 +391,349 @@ func TestPermsSyncer_syncUserPerms_fetchAccount(t *testing.T) { // If we hit a temporary error from the provider we should fetch existing // permissions from the database func TestPermsSyncer_syncUserPermsTemporaryProviderError(t *testing.T) { - p := &mockProvider{ - id: 1, - serviceType: extsvc.TypeGitLab, - serviceID: "https://gitlab.com/", - } - authz.SetProviders(false, []authz.Provider{p}) - t.Cleanup(func() { - authz.SetProviders(true, nil) - }) + t.Run("no existing permissions", func(t *testing.T) { + p := &mockProvider{ + id: 1, + serviceType: extsvc.TypeGitLab, + serviceID: "https://gitlab.com/", + } + authz.SetProviders(false, []authz.Provider{p}) + t.Cleanup(func() { + authz.SetProviders(true, nil) + }) - extAccount := extsvc.Account{ - AccountSpec: extsvc.AccountSpec{ - ServiceType: p.ServiceType(), - ServiceID: p.ServiceID(), - }, - } - - users := dbmocks.NewMockUserStore() - users.GetByIDFunc.SetDefaultHook(func(ctx context.Context, id int32) (*types.User, error) { - return &types.User{ID: id}, nil - }) - - mockRepos := dbmocks.NewMockRepoStore() - mockRepos.ListMinimalReposFunc.SetDefaultHook(func(ctx context.Context, opt database.ReposListOptions) ([]types.MinimalRepo, error) { - if !opt.OnlyPrivate { - return nil, errors.New("OnlyPrivate want true but got false") + extAccount := extsvc.Account{ + AccountSpec: extsvc.AccountSpec{ + ServiceType: p.ServiceType(), + ServiceID: p.ServiceID(), + }, } - names := make([]types.MinimalRepo, 0, len(opt.ExternalRepos)) - for _, r := range opt.ExternalRepos { - id, _ := strconv.Atoi(r.ID) - names = append(names, types.MinimalRepo{ID: api.RepoID(id)}) + users := dbmocks.NewMockUserStore() + users.GetByIDFunc.SetDefaultHook(func(ctx context.Context, id int32) (*types.User, error) { + return &types.User{ID: id}, nil + }) + + mockRepos := dbmocks.NewMockRepoStore() + mockRepos.ListMinimalReposFunc.SetDefaultHook(func(ctx context.Context, opt database.ReposListOptions) ([]types.MinimalRepo, error) { + if !opt.OnlyPrivate { + return nil, errors.New("OnlyPrivate want true but got false") + } + + names := make([]types.MinimalRepo, 0, len(opt.ExternalRepos)) + for _, r := range opt.ExternalRepos { + id, _ := strconv.Atoi(r.ID) + names = append(names, types.MinimalRepo{ID: api.RepoID(id)}) + } + return names, nil + }) + + userEmails := dbmocks.NewMockUserEmailsStore() + + externalAccounts := dbmocks.NewMockUserExternalAccountsStore() + externalAccounts.ListFunc.SetDefaultHook(func(_ context.Context, opts database.ExternalAccountsListOptions) ([]*extsvc.Account, error) { + if opts.OnlyExpired { + return []*extsvc.Account{}, nil + } + return []*extsvc.Account{&extAccount}, nil + }) + featureFlags := dbmocks.NewMockFeatureFlagStore() + + subRepoPerms := dbmocks.NewMockSubRepoPermsStore() + subRepoPerms.GetByUserAndServiceFunc.SetDefaultReturn(nil, nil) + + syncJobs := dbmocks.NewMockPermissionSyncJobStore() + syncJobs.GetLatestFinishedSyncJobFunc.SetDefaultReturn(nil, nil) + + db := dbmocks.NewMockDB() + db.UsersFunc.SetDefaultReturn(users) + db.ReposFunc.SetDefaultReturn(mockRepos) + db.UserEmailsFunc.SetDefaultReturn(userEmails) + db.UserExternalAccountsFunc.SetDefaultReturn(externalAccounts) + db.SubRepoPermsFunc.SetDefaultReturn(subRepoPerms) + db.FeatureFlagsFunc.SetDefaultReturn(featureFlags) + db.PermissionSyncJobsFunc.SetDefaultReturn(syncJobs) + + reposStore := repos.NewMockStoreFrom(repos.NewStore(logtest.Scoped(t), db)) + reposStore.RepoStoreFunc.SetDefaultReturn(mockRepos) + + perms := dbmocks.NewMockPermsStore() + perms.SetUserExternalAccountPermsFunc.SetDefaultHook(func(_ context.Context, user authz.UserIDWithExternalAccountID, repoIDs []int32, source authz.PermsSource) (*database.SetPermissionsResult, error) { + assert.Equal(t, []int32{}, repoIDs) + return &database.SetPermissionsResult{}, nil + }) + + s := newPermsSyncer(logtest.Scoped(t), db, reposStore, perms, timeutil.Now) + + p.fetchUserPerms = func(context.Context, *extsvc.Account) (*authz.ExternalUserPermissions, error) { + // DeadlineExceeded implements the Temporary interface + return nil, context.DeadlineExceeded } - return names, nil - }) - userEmails := dbmocks.NewMockUserEmailsStore() - - externalAccounts := dbmocks.NewMockUserExternalAccountsStore() - externalAccounts.ListFunc.SetDefaultHook(func(_ context.Context, opts database.ExternalAccountsListOptions) ([]*extsvc.Account, error) { - if opts.OnlyExpired { - return []*extsvc.Account{}, nil + _, providers, err := s.syncUserPerms(context.Background(), 1, true, authz.FetchPermsOptions{}) + if err != nil { + t.Fatal(err) } - return []*extsvc.Account{&extAccount}, nil - }) - featureFlags := dbmocks.NewMockFeatureFlagStore() - - subRepoPerms := dbmocks.NewMockSubRepoPermsStore() - subRepoPerms.GetByUserAndServiceFunc.SetDefaultReturn(nil, nil) - - syncJobs := dbmocks.NewMockPermissionSyncJobStore() - syncJobs.GetLatestFinishedSyncJobFunc.SetDefaultReturn(nil, nil) - - db := dbmocks.NewMockDB() - db.UsersFunc.SetDefaultReturn(users) - db.ReposFunc.SetDefaultReturn(mockRepos) - db.UserEmailsFunc.SetDefaultReturn(userEmails) - db.UserExternalAccountsFunc.SetDefaultReturn(externalAccounts) - db.SubRepoPermsFunc.SetDefaultReturn(subRepoPerms) - db.FeatureFlagsFunc.SetDefaultReturn(featureFlags) - db.PermissionSyncJobsFunc.SetDefaultReturn(syncJobs) - - reposStore := repos.NewMockStoreFrom(repos.NewStore(logtest.Scoped(t), db)) - reposStore.RepoStoreFunc.SetDefaultReturn(mockRepos) - - perms := dbmocks.NewMockPermsStore() - perms.SetUserExternalAccountPermsFunc.SetDefaultHook(func(_ context.Context, user authz.UserIDWithExternalAccountID, repoIDs []int32, source authz.PermsSource) (*database.SetPermissionsResult, error) { - assert.Equal(t, []int32{}, repoIDs) - return &database.SetPermissionsResult{}, nil + assert.Equal(t, database.CodeHostStatusesSet{{ + ProviderID: "https://gitlab.com/", + ProviderType: "gitlab", + Status: database.CodeHostStatusError, + Message: "FetchUserPerms: context deadline exceeded", + }}, providers) }) - s := newPermsSyncer(logtest.Scoped(t), db, reposStore, perms, timeutil.Now) + t.Run("reinsert permissions with IP address on temporary error", func(t *testing.T) { + p := &mockProvider{ + id: 1, + serviceType: extsvc.TypeGitLab, + serviceID: "https://gitlab.com/", + } + authz.SetProviders(false, []authz.Provider{p}) + t.Cleanup(func() { + authz.SetProviders(true, nil) + }) - p.fetchUserPerms = func(context.Context, *extsvc.Account) (*authz.ExternalUserPermissions, error) { - // DeadlineExceeded implements the Temporary interface - return nil, context.DeadlineExceeded - } + extAccount := extsvc.Account{ + AccountSpec: extsvc.AccountSpec{ + ServiceType: p.ServiceType(), + ServiceID: p.ServiceID(), + }, + } + + users := dbmocks.NewMockUserStore() + users.GetByIDFunc.SetDefaultHook(func(ctx context.Context, id int32) (*types.User, error) { + return &types.User{ID: id}, nil + }) + + mockRepos := dbmocks.NewMockRepoStore() + mockRepos.ListMinimalReposFunc.SetDefaultHook(func(ctx context.Context, opt database.ReposListOptions) ([]types.MinimalRepo, error) { + if !opt.OnlyPrivate { + return nil, errors.New("OnlyPrivate want true but got false") + } + + names := make([]types.MinimalRepo, 0, len(opt.ExternalRepos)) + for _, r := range opt.ExternalRepos { + id, _ := strconv.Atoi(r.ID) + names = append(names, types.MinimalRepo{ID: api.RepoID(id)}) + } + return names, nil + }) + + userEmails := dbmocks.NewMockUserEmailsStore() + + externalAccounts := dbmocks.NewMockUserExternalAccountsStore() + externalAccounts.ListFunc.SetDefaultHook(func(_ context.Context, opts database.ExternalAccountsListOptions) ([]*extsvc.Account, error) { + if opts.OnlyExpired { + return []*extsvc.Account{}, nil + } + return []*extsvc.Account{&extAccount}, nil + }) + featureFlags := dbmocks.NewMockFeatureFlagStore() + + subRepoPerms := dbmocks.NewMockSubRepoPermsStore() + + // Set up initial state with permissions entry including IP address + initialPerms := map[api.ExternalRepoSpec]authz.SubRepoPermissionsWithIPs{ + {ID: "repo1", ServiceType: p.ServiceType(), ServiceID: p.ServiceID()}: { + Paths: []authz.PathWithIP{{Path: "/include1", IP: "1.1.1.1"}}, + }, + } + subRepoPerms.GetByUserAndServiceWithIPsFunc.SetDefaultReturn(initialPerms, nil) + + // Set up spy for UpsertWithSpecWithIPs + var upsertCallCount int + + subRepoPerms.UpsertWithSpecWithIPsFunc.SetDefaultHook(func(ctx context.Context, userID int32, spec api.ExternalRepoSpec, perms authz.SubRepoPermissionsWithIPs) error { + upsertCallCount++ + + // Check that we're re-inserting the same permissions + assert.Equal(t, int32(1), userID, "Incorrect user ID passed to UpsertWithSpecWithIPs") + assert.Equal(t, api.ExternalRepoSpec{ID: "repo1", ServiceType: p.ServiceType(), ServiceID: p.ServiceID()}, spec, "Incorrect spec passed to UpsertWithSpecWithIPs") + assert.Equal(t, initialPerms[spec], perms, "Incorrect permissions passed to UpsertWithSpecWithIPs") + return nil + }) + + syncJobs := dbmocks.NewMockPermissionSyncJobStore() + syncJobs.GetLatestFinishedSyncJobFunc.SetDefaultReturn(nil, nil) + + db := dbmocks.NewMockDB() + db.UsersFunc.SetDefaultReturn(users) + db.ReposFunc.SetDefaultReturn(mockRepos) + db.UserEmailsFunc.SetDefaultReturn(userEmails) + db.UserExternalAccountsFunc.SetDefaultReturn(externalAccounts) + db.SubRepoPermsFunc.SetDefaultReturn(subRepoPerms) + db.FeatureFlagsFunc.SetDefaultReturn(featureFlags) + db.PermissionSyncJobsFunc.SetDefaultReturn(syncJobs) + + reposStore := repos.NewMockStoreFrom(repos.NewStore(logtest.Scoped(t), db)) + reposStore.RepoStoreFunc.SetDefaultReturn(mockRepos) + + perms := dbmocks.NewMockPermsStore() + perms.SetUserExternalAccountPermsFunc.SetDefaultHook(func(_ context.Context, user authz.UserIDWithExternalAccountID, repoIDs []int32, source authz.PermsSource) (*database.SetPermissionsResult, error) { + assert.Equal(t, []int32{}, repoIDs) + return &database.SetPermissionsResult{}, nil + }) + + s := newPermsSyncer(logtest.Scoped(t), db, reposStore, perms, timeutil.Now) + + // Set up initial state with permissions entry including IP address + + p.fetchUserPerms = func(context.Context, *extsvc.Account) (*authz.ExternalUserPermissions, error) { + return nil, context.DeadlineExceeded + } + + _, providers, err := s.syncUserPerms(context.Background(), 1, true, authz.FetchPermsOptions{}) + if err != nil { + t.Fatal(err) + } + + // Verify that the code host status is set to error + assert.Equal(t, database.CodeHostStatusesSet{{ + ProviderID: "https://gitlab.com/", + ProviderType: "gitlab", + Status: database.CodeHostStatusError, + Message: "FetchUserPerms: context deadline exceeded", + }}, providers) + + // Verify that the UpsertWithSpecWithIPs was called + assert.Equal(t, 1, upsertCallCount, "UpsertWithSpecWithIPs should have been called once") + }) + + t.Run("reinsert permissions without address on temporary error", func(t *testing.T) { + p := &mockProvider{ + id: 1, + serviceType: extsvc.TypeGitLab, + serviceID: "https://gitlab.com/", + } + authz.SetProviders(false, []authz.Provider{p}) + t.Cleanup(func() { + authz.SetProviders(true, nil) + }) + + extAccount := extsvc.Account{ + AccountSpec: extsvc.AccountSpec{ + ServiceType: p.ServiceType(), + ServiceID: p.ServiceID(), + }, + } + + users := dbmocks.NewMockUserStore() + users.GetByIDFunc.SetDefaultHook(func(ctx context.Context, id int32) (*types.User, error) { + return &types.User{ID: id}, nil + }) + + mockRepos := dbmocks.NewMockRepoStore() + mockRepos.ListMinimalReposFunc.SetDefaultHook(func(ctx context.Context, opt database.ReposListOptions) ([]types.MinimalRepo, error) { + if !opt.OnlyPrivate { + return nil, errors.New("OnlyPrivate want true but got false") + } + + names := make([]types.MinimalRepo, 0, len(opt.ExternalRepos)) + for _, r := range opt.ExternalRepos { + id, _ := strconv.Atoi(r.ID) + names = append(names, types.MinimalRepo{ID: api.RepoID(id)}) + } + return names, nil + }) + + userEmails := dbmocks.NewMockUserEmailsStore() + + externalAccounts := dbmocks.NewMockUserExternalAccountsStore() + externalAccounts.ListFunc.SetDefaultHook(func(_ context.Context, opts database.ExternalAccountsListOptions) ([]*extsvc.Account, error) { + if opts.OnlyExpired { + return []*extsvc.Account{}, nil + } + return []*extsvc.Account{&extAccount}, nil + }) + featureFlags := dbmocks.NewMockFeatureFlagStore() + + subRepoPerms := dbmocks.NewMockSubRepoPermsStore() + + // Set up a fake implementation of IP version of the sub repo permissions getter + // that simulates a database entry without an IP address + var getByUserAndServiceWithIPsCalled bool + subRepoPerms.GetByUserAndServiceWithIPsFunc.SetDefaultHook(func(ctx context.Context, userID int32, serviceType string, serviceID string, backfillWithWildcardIP bool) (map[api.ExternalRepoSpec]authz.SubRepoPermissionsWithIPs, error) { + getByUserAndServiceWithIPsCalled = true + + assert.Equal(t, int32(1), userID, "Incorrect user ID passed to UpsertWithSpec") + assert.Equal(t, p.ServiceType(), serviceType, "Incorrect service type passed to UpsertWithSpec") + assert.Equal(t, p.ServiceID(), serviceID, "Incorrect service ID passed to UpsertWithSpec") + assert.False(t, backfillWithWildcardIP, "backfillWithWildcardIP should be false since we don't want a fake IP address") + + return nil, database.IPsNotSyncedError + }) + + // Set up initial state with permissions entry without an IP address + initialPerms := map[api.ExternalRepoSpec]authz.SubRepoPermissions{ + {ID: "repo1", ServiceType: p.ServiceType(), ServiceID: p.ServiceID()}: { + Paths: []string{"/include1"}, + }, + } + subRepoPerms.GetByUserAndServiceFunc.SetDefaultReturn(initialPerms, nil) + + // Set up spy for UpsertWithSpec + + var upsertCallCount int + + subRepoPerms.UpsertWithSpecFunc.SetDefaultHook(func(ctx context.Context, userID int32, spec api.ExternalRepoSpec, perms authz.SubRepoPermissions) error { + upsertCallCount++ + + // Check that we're re-inserting the same permissions + assert.Equal(t, int32(1), userID, "Incorrect user ID passed to UpsertWithSpec") + assert.Equal(t, api.ExternalRepoSpec{ID: "repo1", ServiceType: p.ServiceType(), ServiceID: p.ServiceID()}, spec, "Incorrect spec passed to UpsertWithSpec") + assert.Equal(t, initialPerms[spec], perms, "Incorrect permissions passed to UpsertWithSpec") + return nil + }) + + syncJobs := dbmocks.NewMockPermissionSyncJobStore() + syncJobs.GetLatestFinishedSyncJobFunc.SetDefaultReturn(nil, nil) + + db := dbmocks.NewMockDB() + db.UsersFunc.SetDefaultReturn(users) + db.ReposFunc.SetDefaultReturn(mockRepos) + db.UserEmailsFunc.SetDefaultReturn(userEmails) + db.UserExternalAccountsFunc.SetDefaultReturn(externalAccounts) + db.SubRepoPermsFunc.SetDefaultReturn(subRepoPerms) + db.FeatureFlagsFunc.SetDefaultReturn(featureFlags) + db.PermissionSyncJobsFunc.SetDefaultReturn(syncJobs) + + reposStore := repos.NewMockStoreFrom(repos.NewStore(logtest.Scoped(t), db)) + reposStore.RepoStoreFunc.SetDefaultReturn(mockRepos) + + perms := dbmocks.NewMockPermsStore() + perms.SetUserExternalAccountPermsFunc.SetDefaultHook(func(_ context.Context, user authz.UserIDWithExternalAccountID, repoIDs []int32, source authz.PermsSource) (*database.SetPermissionsResult, error) { + assert.Equal(t, []int32{}, repoIDs) + return &database.SetPermissionsResult{}, nil + }) + + s := newPermsSyncer(logtest.Scoped(t), db, reposStore, perms, timeutil.Now) + + // Set up initial state with permissions entry including IP address + + p.fetchUserPerms = func(context.Context, *extsvc.Account) (*authz.ExternalUserPermissions, error) { + return nil, context.DeadlineExceeded + } + + _, providers, err := s.syncUserPerms(context.Background(), 1, true, authz.FetchPermsOptions{}) + if err != nil { + t.Fatal(err) + } + + // Verify that the code host status is set to error + assert.Equal(t, database.CodeHostStatusesSet{{ + ProviderID: "https://gitlab.com/", + ProviderType: "gitlab", + Status: database.CodeHostStatusError, + Message: "FetchUserPerms: context deadline exceeded", + }}, providers) + + // Verify that fake version of the sub repo permissions getter that forced a fallback was called + assert.True(t, getByUserAndServiceWithIPsCalled, "getByUserWithIPsFunc should have been called") + + // Verify that the UpsertWithSpec (non IP version) was called once + assert.Equal(t, 1, upsertCallCount, "UpsertWithSpec should have been called once") + }) - _, providers, err := s.syncUserPerms(context.Background(), 1, true, authz.FetchPermsOptions{}) - if err != nil { - t.Fatal(err) - } - assert.Equal(t, database.CodeHostStatusesSet{{ - ProviderID: "https://gitlab.com/", - ProviderType: "gitlab", - Status: database.CodeHostStatusError, - Message: "FetchUserPerms: context deadline exceeded", - }}, providers) } func TestPermsSyncer_syncUserPerms_noPerms(t *testing.T) {