From b4eca8c776154e951c59cf393a00f261f2268abb Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 16 Nov 2024 12:53:34 -0800 Subject: [PATCH] Move team related functions to service layer --- models/org_team.go | 513 ------------------ models/organization/team_test.go | 5 + routers/api/v1/org/member.go | 4 +- routers/api/v1/org/team.go | 13 +- routers/api/v1/repo/teams.go | 3 +- routers/web/org/members.go | 6 +- routers/web/org/teams.go | 23 +- routers/web/repo/setting/collaboration.go | 3 +- services/auth/source/source_group_sync.go | 6 +- services/doctor/fix8312.go | 4 +- services/org/repo.go | 27 - services/org/team.go | 402 ++++++++++++++ .../org/team_test.go | 141 ++++- models/org.go => services/org/user.go | 2 +- .../org_test.go => services/org/user_test.go | 2 +- services/repository/create.go | 3 +- services/repository/create_test.go | 148 ----- services/repository/delete.go | 81 --- services/repository/repo_team.go | 226 ++++++++ .../repo_team_test.go} | 2 +- services/repository/transfer.go | 4 +- services/user/user.go | 2 +- services/user/user_test.go | 3 +- tests/integration/auth_ldap_test.go | 8 +- 24 files changed, 809 insertions(+), 822 deletions(-) delete mode 100644 services/org/repo.go create mode 100644 services/org/team.go rename models/org_team_test.go => services/org/team_test.go (61%) rename models/org.go => services/org/user.go (99%) rename models/org_test.go => services/org/user_test.go (99%) delete mode 100644 services/repository/create_test.go create mode 100644 services/repository/repo_team.go rename services/{org/repo_test.go => repository/repo_team_test.go} (98%) diff --git a/models/org_team.go b/models/org_team.go index b6908478c7..e808369d29 100644 --- a/models/org_team.go +++ b/models/org_team.go @@ -7,529 +7,16 @@ package models import ( "context" "fmt" - "strings" "code.gitea.io/gitea/models/db" - git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/models/organization" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "xorm.io/builder" ) -func AddRepository(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) { - if err = organization.AddTeamRepo(ctx, t.OrgID, t.ID, repo.ID); err != nil { - return err - } - - if err = organization.IncrTeamRepoNum(ctx, t.ID); err != nil { - return fmt.Errorf("update team: %w", err) - } - - t.NumRepos++ - - if err = access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil { - return fmt.Errorf("recalculateAccesses: %w", err) - } - - // Make all team members watch this repo if enabled in global settings - if setting.Service.AutoWatchNewRepos { - if err = t.LoadMembers(ctx); err != nil { - return fmt.Errorf("getMembers: %w", err) - } - for _, u := range t.Members { - if err = repo_model.WatchRepo(ctx, u, repo, true); err != nil { - return fmt.Errorf("watchRepo: %w", err) - } - } - } - - return nil -} - -// addAllRepositories adds all repositories to the team. -// If the team already has some repositories they will be left unchanged. -func addAllRepositories(ctx context.Context, t *organization.Team) error { - orgRepos, err := organization.GetOrgRepositories(ctx, t.OrgID) - if err != nil { - return fmt.Errorf("get org repos: %w", err) - } - - for _, repo := range orgRepos { - if !organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) { - if err := AddRepository(ctx, t, repo); err != nil { - return fmt.Errorf("AddRepository: %w", err) - } - } - } - - return nil -} - -// AddAllRepositories adds all repositories to the team -func AddAllRepositories(ctx context.Context, t *organization.Team) (err error) { - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() - - if err = addAllRepositories(ctx, t); err != nil { - return err - } - - return committer.Commit() -} - -// RemoveAllRepositories removes all repositories from team and recalculates access -func RemoveAllRepositories(ctx context.Context, t *organization.Team) (err error) { - if t.IncludesAllRepositories { - return nil - } - - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() - - if err = removeAllRepositories(ctx, t); err != nil { - return err - } - - return committer.Commit() -} - -// removeAllRepositories removes all repositories from team and recalculates access -// Note: Shall not be called if team includes all repositories -func removeAllRepositories(ctx context.Context, t *organization.Team) (err error) { - e := db.GetEngine(ctx) - // Delete all accesses. - for _, repo := range t.Repos { - if err := access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil { - return err - } - - // Remove watches from all users and now unaccessible repos - for _, user := range t.Members { - has, err := access_model.HasAnyUnitAccess(ctx, user.ID, repo) - if err != nil { - return err - } else if has { - continue - } - - if err = repo_model.WatchRepo(ctx, user, repo, false); err != nil { - return err - } - - // Remove all IssueWatches a user has subscribed to in the repositories - if err = issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID); err != nil { - return err - } - } - } - - // Delete team-repo - if _, err := e. - Where("team_id=?", t.ID). - Delete(new(organization.TeamRepo)); err != nil { - return err - } - - t.NumRepos = 0 - if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil { - return err - } - - return nil -} - -// NewTeam creates a record of new team. -// It's caller's responsibility to assign organization ID. -func NewTeam(ctx context.Context, t *organization.Team) (err error) { - if len(t.Name) == 0 { - return util.NewInvalidArgumentErrorf("empty team name") - } - - if err = organization.IsUsableTeamName(t.Name); err != nil { - return err - } - - has, err := db.ExistByID[user_model.User](ctx, t.OrgID) - if err != nil { - return err - } - if !has { - return organization.ErrOrgNotExist{ID: t.OrgID} - } - - t.LowerName = strings.ToLower(t.Name) - has, err = db.Exist[organization.Team](ctx, builder.Eq{ - "org_id": t.OrgID, - "lower_name": t.LowerName, - }) - if err != nil { - return err - } - if has { - return organization.ErrTeamAlreadyExist{OrgID: t.OrgID, Name: t.LowerName} - } - - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() - - if err = db.Insert(ctx, t); err != nil { - return err - } - - // insert units for team - if len(t.Units) > 0 { - for _, unit := range t.Units { - unit.TeamID = t.ID - } - if err = db.Insert(ctx, &t.Units); err != nil { - return err - } - } - - // Add all repositories to the team if it has access to all of them. - if t.IncludesAllRepositories { - err = addAllRepositories(ctx, t) - if err != nil { - return fmt.Errorf("addAllRepositories: %w", err) - } - } - - // Update organization number of teams. - if _, err = db.Exec(ctx, "UPDATE `user` SET num_teams=num_teams+1 WHERE id = ?", t.OrgID); err != nil { - return err - } - return committer.Commit() -} - -// UpdateTeam updates information of team. -func UpdateTeam(ctx context.Context, t *organization.Team, authChanged, includeAllChanged bool) (err error) { - if len(t.Name) == 0 { - return util.NewInvalidArgumentErrorf("empty team name") - } - - if len(t.Description) > 255 { - t.Description = t.Description[:255] - } - - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() - - t.LowerName = strings.ToLower(t.Name) - has, err := db.Exist[organization.Team](ctx, builder.Eq{ - "org_id": t.OrgID, - "lower_name": t.LowerName, - }.And(builder.Neq{"id": t.ID}), - ) - if err != nil { - return err - } else if has { - return organization.ErrTeamAlreadyExist{OrgID: t.OrgID, Name: t.LowerName} - } - - sess := db.GetEngine(ctx) - if _, err = sess.ID(t.ID).Cols("name", "lower_name", "description", - "can_create_org_repo", "authorize", "includes_all_repositories").Update(t); err != nil { - return fmt.Errorf("update: %w", err) - } - - // update units for team - if len(t.Units) > 0 { - for _, unit := range t.Units { - unit.TeamID = t.ID - } - // Delete team-unit. - if _, err := sess. - Where("team_id=?", t.ID). - Delete(new(organization.TeamUnit)); err != nil { - return err - } - if _, err = sess.Cols("org_id", "team_id", "type", "access_mode").Insert(&t.Units); err != nil { - return err - } - } - - // Update access for team members if needed. - if authChanged { - if err = t.LoadRepositories(ctx); err != nil { - return fmt.Errorf("LoadRepositories: %w", err) - } - - for _, repo := range t.Repos { - if err = access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil { - return fmt.Errorf("recalculateTeamAccesses: %w", err) - } - } - } - - // Add all repositories to the team if it has access to all of them. - if includeAllChanged && t.IncludesAllRepositories { - err = addAllRepositories(ctx, t) - if err != nil { - return fmt.Errorf("addAllRepositories: %w", err) - } - } - - return committer.Commit() -} - -// DeleteTeam deletes given team. -// It's caller's responsibility to assign organization ID. -func DeleteTeam(ctx context.Context, t *organization.Team) error { - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() - - if err := t.LoadRepositories(ctx); err != nil { - return err - } - - if err := t.LoadMembers(ctx); err != nil { - return err - } - - // update branch protections - { - protections := make([]*git_model.ProtectedBranch, 0, 10) - err := db.GetEngine(ctx).In("repo_id", - builder.Select("id").From("repository").Where(builder.Eq{"owner_id": t.OrgID})). - Find(&protections) - if err != nil { - return fmt.Errorf("findProtectedBranches: %w", err) - } - for _, p := range protections { - if err := git_model.RemoveTeamIDFromProtectedBranch(ctx, p, t.ID); err != nil { - return err - } - } - } - - if !t.IncludesAllRepositories { - if err := removeAllRepositories(ctx, t); err != nil { - return err - } - } - - if err := db.DeleteBeans(ctx, - &organization.Team{ID: t.ID}, - &organization.TeamUser{OrgID: t.OrgID, TeamID: t.ID}, - &organization.TeamUnit{TeamID: t.ID}, - &organization.TeamInvite{TeamID: t.ID}, - &issues_model.Review{Type: issues_model.ReviewTypeRequest, ReviewerTeamID: t.ID}, // batch delete the binding relationship between team and PR (request review from team) - ); err != nil { - return err - } - - for _, tm := range t.Members { - if err := removeInvalidOrgUser(ctx, t.OrgID, tm); err != nil { - return err - } - } - - // Update organization number of teams. - if _, err := db.Exec(ctx, "UPDATE `user` SET num_teams=num_teams-1 WHERE id=?", t.OrgID); err != nil { - return err - } - - return committer.Commit() -} - -// AddTeamMember adds new membership of given team to given organization, -// the user will have membership to given organization automatically when needed. -func AddTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error { - if user_model.IsUserBlockedBy(ctx, user, team.OrgID) { - return user_model.ErrBlockedUser - } - - isAlreadyMember, err := organization.IsTeamMember(ctx, team.OrgID, team.ID, user.ID) - if err != nil || isAlreadyMember { - return err - } - - if err := organization.AddOrgUser(ctx, team.OrgID, user.ID); err != nil { - return err - } - - err = db.WithTx(ctx, func(ctx context.Context) error { - // check in transaction - isAlreadyMember, err = organization.IsTeamMember(ctx, team.OrgID, team.ID, user.ID) - if err != nil || isAlreadyMember { - return err - } - - sess := db.GetEngine(ctx) - - if err := db.Insert(ctx, &organization.TeamUser{ - UID: user.ID, - OrgID: team.OrgID, - TeamID: team.ID, - }); err != nil { - return err - } else if _, err := sess.Incr("num_members").ID(team.ID).Update(new(organization.Team)); err != nil { - return err - } - - team.NumMembers++ - - // Give access to team repositories. - // update exist access if mode become bigger - subQuery := builder.Select("repo_id").From("team_repo"). - Where(builder.Eq{"team_id": team.ID}) - - if _, err := sess.Where("user_id=?", user.ID). - In("repo_id", subQuery). - And("mode < ?", team.AccessMode). - SetExpr("mode", team.AccessMode). - Update(new(access_model.Access)); err != nil { - return fmt.Errorf("update user accesses: %w", err) - } - - // for not exist access - var repoIDs []int64 - accessSubQuery := builder.Select("repo_id").From("access").Where(builder.Eq{"user_id": user.ID}) - if err := sess.SQL(subQuery.And(builder.NotIn("repo_id", accessSubQuery))).Find(&repoIDs); err != nil { - return fmt.Errorf("select id accesses: %w", err) - } - - accesses := make([]*access_model.Access, 0, 100) - for i, repoID := range repoIDs { - accesses = append(accesses, &access_model.Access{RepoID: repoID, UserID: user.ID, Mode: team.AccessMode}) - if (i%100 == 0 || i == len(repoIDs)-1) && len(accesses) > 0 { - if err = db.Insert(ctx, accesses); err != nil { - return fmt.Errorf("insert new user accesses: %w", err) - } - accesses = accesses[:0] - } - } - return nil - }) - if err != nil { - return err - } - - // this behaviour may spend much time so run it in a goroutine - // FIXME: Update watch repos batchly - if setting.Service.AutoWatchNewRepos { - // Get team and its repositories. - if err := team.LoadRepositories(ctx); err != nil { - log.Error("team.LoadRepositories failed: %v", err) - } - - // FIXME: in the goroutine, it can't access the "ctx", it could only use db.DefaultContext at the moment - go func(repos []*repo_model.Repository) { - for _, repo := range repos { - if err = repo_model.WatchRepo(db.DefaultContext, user, repo, true); err != nil { - log.Error("watch repo failed: %v", err) - } - } - }(team.Repos) - } - - return nil -} - -func removeTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error { - e := db.GetEngine(ctx) - isMember, err := organization.IsTeamMember(ctx, team.OrgID, team.ID, user.ID) - if err != nil || !isMember { - return err - } - - // Check if the user to delete is the last member in owner team. - if team.IsOwnerTeam() && team.NumMembers == 1 { - return organization.ErrLastOrgOwner{UID: user.ID} - } - - team.NumMembers-- - - if err := team.LoadRepositories(ctx); err != nil { - return err - } - - if _, err := e.Delete(&organization.TeamUser{ - UID: user.ID, - OrgID: team.OrgID, - TeamID: team.ID, - }); err != nil { - return err - } else if _, err = e. - ID(team.ID). - Cols("num_members"). - Update(team); err != nil { - return err - } - - // Delete access to team repositories. - for _, repo := range team.Repos { - if err := access_model.RecalculateUserAccess(ctx, repo, user.ID); err != nil { - return err - } - - // Remove watches from now unaccessible - if err := ReconsiderWatches(ctx, repo, user); err != nil { - return err - } - - // Remove issue assignments from now unaccessible - if err := ReconsiderRepoIssuesAssignee(ctx, repo, user); err != nil { - return err - } - } - - return removeInvalidOrgUser(ctx, team.OrgID, user) -} - -func removeInvalidOrgUser(ctx context.Context, orgID int64, user *user_model.User) error { - // Check if the user is a member of any team in the organization. - if count, err := db.GetEngine(ctx).Count(&organization.TeamUser{ - UID: user.ID, - OrgID: orgID, - }); err != nil { - return err - } else if count == 0 { - org, err := organization.GetOrgByID(ctx, orgID) - if err != nil { - return err - } - - return RemoveOrgUser(ctx, org, user) - } - return nil -} - -// RemoveTeamMember removes member from given team of given organization. -func RemoveTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error { - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() - if err := removeTeamMember(ctx, team, user); err != nil { - return err - } - return committer.Commit() -} - func ReconsiderRepoIssuesAssignee(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error { if canAssigned, err := access_model.CanBeAssigned(ctx, user, repo, true); err != nil || canAssigned { return err diff --git a/models/organization/team_test.go b/models/organization/team_test.go index 23a6affe24..8c34e7a612 100644 --- a/models/organization/team_test.go +++ b/models/organization/team_test.go @@ -197,3 +197,8 @@ func TestUsersInTeamsCount(t *testing.T) { test([]int64{1, 2, 3, 4, 5}, []int64{2, 5}, 2) // userid 2,4 test([]int64{1, 2, 3, 4, 5}, []int64{2, 3, 5}, 3) // userid 2,4,5 } + +func TestIsUsableTeamName(t *testing.T) { + assert.NoError(t, organization.IsUsableTeamName("usable")) + assert.True(t, db.IsErrNameReserved(organization.IsUsableTeamName("new"))) +} diff --git a/routers/api/v1/org/member.go b/routers/api/v1/org/member.go index edcee1e207..294d33014d 100644 --- a/routers/api/v1/org/member.go +++ b/routers/api/v1/org/member.go @@ -7,7 +7,6 @@ import ( "net/http" "net/url" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -15,6 +14,7 @@ import ( "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" + org_service "code.gitea.io/gitea/services/org" ) // listMembers list an organization's members @@ -322,7 +322,7 @@ func DeleteMember(ctx *context.APIContext) { if ctx.Written() { return } - if err := models.RemoveOrgUser(ctx, ctx.Org.Organization, member); err != nil { + if err := org_service.RemoveOrgUser(ctx, ctx.Org.Organization, member); err != nil { ctx.Error(http.StatusInternalServerError, "RemoveOrgUser", err) } ctx.Status(http.StatusNoContent) diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index c55837ff44..20226b4d6b 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -8,7 +8,6 @@ import ( "errors" "net/http" - "code.gitea.io/gitea/models" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" @@ -240,7 +239,7 @@ func CreateTeam(ctx *context.APIContext) { attachAdminTeamUnits(team) } - if err := models.NewTeam(ctx, team); err != nil { + if err := org_service.NewTeam(ctx, team); err != nil { if organization.IsErrTeamAlreadyExist(err) { ctx.Error(http.StatusUnprocessableEntity, "", err) } else { @@ -331,7 +330,7 @@ func EditTeam(ctx *context.APIContext) { attachAdminTeamUnits(team) } - if err := models.UpdateTeam(ctx, team, isAuthChanged, isIncludeAllChanged); err != nil { + if err := org_service.UpdateTeam(ctx, team, isAuthChanged, isIncludeAllChanged); err != nil { ctx.Error(http.StatusInternalServerError, "EditTeam", err) return } @@ -362,7 +361,7 @@ func DeleteTeam(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if err := models.DeleteTeam(ctx, ctx.Org.Team); err != nil { + if err := org_service.DeleteTeam(ctx, ctx.Org.Team); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteTeam", err) return } @@ -496,7 +495,7 @@ func AddTeamMember(ctx *context.APIContext) { if ctx.Written() { return } - if err := models.AddTeamMember(ctx, ctx.Org.Team, u); err != nil { + if err := org_service.AddTeamMember(ctx, ctx.Org.Team, u); err != nil { if errors.Is(err, user_model.ErrBlockedUser) { ctx.Error(http.StatusForbidden, "AddTeamMember", err) } else { @@ -537,7 +536,7 @@ func RemoveTeamMember(ctx *context.APIContext) { return } - if err := models.RemoveTeamMember(ctx, ctx.Org.Team, u); err != nil { + if err := org_service.RemoveTeamMember(ctx, ctx.Org.Team, u); err != nil { ctx.Error(http.StatusInternalServerError, "RemoveTeamMember", err) return } @@ -700,7 +699,7 @@ func AddTeamRepository(ctx *context.APIContext) { ctx.Error(http.StatusForbidden, "", "Must have admin-level access to the repository") return } - if err := org_service.TeamAddRepository(ctx, ctx.Org.Team, repo); err != nil { + if err := repo_service.TeamAddRepository(ctx, ctx.Org.Team, repo); err != nil { ctx.Error(http.StatusInternalServerError, "TeamAddRepository", err) return } diff --git a/routers/api/v1/repo/teams.go b/routers/api/v1/repo/teams.go index ddd325482d..82ecaf3020 100644 --- a/routers/api/v1/repo/teams.go +++ b/routers/api/v1/repo/teams.go @@ -10,7 +10,6 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" - org_service "code.gitea.io/gitea/services/org" repo_service "code.gitea.io/gitea/services/repository" ) @@ -205,7 +204,7 @@ func changeRepoTeam(ctx *context.APIContext, add bool) { ctx.Error(http.StatusUnprocessableEntity, "alreadyAdded", fmt.Errorf("team '%s' is already added to repo", team.Name)) return } - err = org_service.TeamAddRepository(ctx, team, ctx.Repo.Repository) + err = repo_service.TeamAddRepository(ctx, team, ctx.Repo.Repository) } else { if !repoHasTeam { ctx.Error(http.StatusUnprocessableEntity, "notAdded", fmt.Errorf("team '%s' was not added to repo", team.Name)) diff --git a/routers/web/org/members.go b/routers/web/org/members.go index 97dfff3afe..7af087c4df 100644 --- a/routers/web/org/members.go +++ b/routers/web/org/members.go @@ -7,7 +7,6 @@ package org import ( "net/http" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" @@ -15,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/setting" shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" + org_service "code.gitea.io/gitea/services/org" ) const ( @@ -108,14 +108,14 @@ func MembersAction(ctx *context.Context) { ctx.Error(http.StatusNotFound) return } - err = models.RemoveOrgUser(ctx, org, member) + err = org_service.RemoveOrgUser(ctx, org, member) if organization.IsErrLastOrgOwner(err) { ctx.Flash.Error(ctx.Tr("form.last_org_owner")) ctx.JSONRedirect(ctx.Org.OrgLink + "/members") return } case "leave": - err = models.RemoveOrgUser(ctx, org, ctx.Doer) + err = org_service.RemoveOrgUser(ctx, org, ctx.Doer) if err == nil { ctx.Flash.Success(ctx.Tr("form.organization_leave_success", org.DisplayName())) ctx.JSON(http.StatusOK, map[string]any{ diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index 31b9601ce7..bd78832103 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -13,7 +13,6 @@ import ( "strconv" "strings" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" org_model "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" @@ -78,9 +77,9 @@ func TeamsAction(ctx *context.Context) { ctx.Error(http.StatusNotFound) return } - err = models.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer) + err = org_service.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer) case "leave": - err = models.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer) + err = org_service.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer) if err != nil { if org_model.IsErrLastOrgOwner(err) { ctx.Flash.Error(ctx.Tr("form.last_org_owner")) @@ -107,7 +106,7 @@ func TeamsAction(ctx *context.Context) { return } - err = models.RemoveTeamMember(ctx, ctx.Org.Team, user) + err = org_service.RemoveTeamMember(ctx, ctx.Org.Team, user) if err != nil { if org_model.IsErrLastOrgOwner(err) { ctx.Flash.Error(ctx.Tr("form.last_org_owner")) @@ -162,7 +161,7 @@ func TeamsAction(ctx *context.Context) { if ctx.Org.Team.IsMember(ctx, u.ID) { ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users")) } else { - err = models.AddTeamMember(ctx, ctx.Org.Team, u) + err = org_service.AddTeamMember(ctx, ctx.Org.Team, u) } page = "team" @@ -249,13 +248,13 @@ func TeamsRepoAction(ctx *context.Context) { ctx.ServerError("GetRepositoryByName", err) return } - err = org_service.TeamAddRepository(ctx, ctx.Org.Team, repo) + err = repo_service.TeamAddRepository(ctx, ctx.Org.Team, repo) case "remove": err = repo_service.RemoveRepositoryFromTeam(ctx, ctx.Org.Team, ctx.FormInt64("repoid")) case "addall": - err = models.AddAllRepositories(ctx, ctx.Org.Team) + err = repo_service.AddAllRepositoriesToTeam(ctx, ctx.Org.Team) case "removeall": - err = models.RemoveAllRepositories(ctx, ctx.Org.Team) + err = repo_service.RemoveAllRepositoriesFromTeam(ctx, ctx.Org.Team) } if err != nil { @@ -358,7 +357,7 @@ func NewTeamPost(ctx *context.Context) { return } - if err := models.NewTeam(ctx, t); err != nil { + if err := org_service.NewTeam(ctx, t); err != nil { ctx.Data["Err_TeamName"] = true switch { case org_model.IsErrTeamAlreadyExist(err): @@ -536,7 +535,7 @@ func EditTeamPost(ctx *context.Context) { return } - if err := models.UpdateTeam(ctx, t, isAuthChanged, isIncludeAllChanged); err != nil { + if err := org_service.UpdateTeam(ctx, t, isAuthChanged, isIncludeAllChanged); err != nil { ctx.Data["Err_TeamName"] = true switch { case org_model.IsErrTeamAlreadyExist(err): @@ -551,7 +550,7 @@ func EditTeamPost(ctx *context.Context) { // DeleteTeam response for the delete team request func DeleteTeam(ctx *context.Context) { - if err := models.DeleteTeam(ctx, ctx.Org.Team); err != nil { + if err := org_service.DeleteTeam(ctx, ctx.Org.Team); err != nil { ctx.Flash.Error("DeleteTeam: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("org.teams.delete_team_success")) @@ -593,7 +592,7 @@ func TeamInvitePost(ctx *context.Context) { return } - if err := models.AddTeamMember(ctx, team, ctx.Doer); err != nil { + if err := org_service.AddTeamMember(ctx, team, ctx.Doer); err != nil { ctx.ServerError("AddTeamMember", err) return } diff --git a/routers/web/repo/setting/collaboration.go b/routers/web/repo/setting/collaboration.go index 18ecff8250..cdf91edf4a 100644 --- a/routers/web/repo/setting/collaboration.go +++ b/routers/web/repo/setting/collaboration.go @@ -17,7 +17,6 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/mailer" - org_service "code.gitea.io/gitea/services/org" repo_service "code.gitea.io/gitea/services/repository" ) @@ -185,7 +184,7 @@ func AddTeamPost(ctx *context.Context) { return } - if err = org_service.TeamAddRepository(ctx, team, ctx.Repo.Repository); err != nil { + if err = repo_service.TeamAddRepository(ctx, team, ctx.Repo.Repository); err != nil { ctx.ServerError("TeamAddRepository", err) return } diff --git a/services/auth/source/source_group_sync.go b/services/auth/source/source_group_sync.go index 05293f202f..9cb7d4165c 100644 --- a/services/auth/source/source_group_sync.go +++ b/services/auth/source/source_group_sync.go @@ -7,11 +7,11 @@ import ( "context" "fmt" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" + org_service "code.gitea.io/gitea/services/org" ) type syncType int @@ -100,12 +100,12 @@ func syncGroupsToTeamsCached(ctx context.Context, user *user_model.User, orgTeam } if action == syncAdd && !isMember { - if err := models.AddTeamMember(ctx, team, user); err != nil { + if err := org_service.AddTeamMember(ctx, team, user); err != nil { log.Error("group sync: Could not add user to team: %v", err) return err } } else if action == syncRemove && isMember { - if err := models.RemoveTeamMember(ctx, team, user); err != nil { + if err := org_service.RemoveTeamMember(ctx, team, user); err != nil { log.Error("group sync: Could not remove user from team: %v", err) return err } diff --git a/services/doctor/fix8312.go b/services/doctor/fix8312.go index 4fc049873a..3e2ca68eb4 100644 --- a/services/doctor/fix8312.go +++ b/services/doctor/fix8312.go @@ -6,11 +6,11 @@ package doctor import ( "context" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" org_model "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/modules/log" + org_service "code.gitea.io/gitea/services/org" "xorm.io/builder" ) @@ -29,7 +29,7 @@ func fixOwnerTeamCreateOrgRepo(ctx context.Context, logger log.Logger, autofix b return nil } - return models.UpdateTeam(ctx, team, false, false) + return org_service.UpdateTeam(ctx, team, false, false) }, ) if err != nil { diff --git a/services/org/repo.go b/services/org/repo.go deleted file mode 100644 index 78a829ef25..0000000000 --- a/services/org/repo.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package org - -import ( - "context" - "errors" - - "code.gitea.io/gitea/models" - "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/organization" - repo_model "code.gitea.io/gitea/models/repo" -) - -// TeamAddRepository adds new repository to team of organization. -func TeamAddRepository(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) { - if repo.OwnerID != t.OrgID { - return errors.New("repository does not belong to organization") - } else if organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) { - return nil - } - - return db.WithTx(ctx, func(ctx context.Context) error { - return models.AddRepository(ctx, t, repo) - }) -} diff --git a/services/org/team.go b/services/org/team.go new file mode 100644 index 0000000000..16675593a7 --- /dev/null +++ b/services/org/team.go @@ -0,0 +1,402 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + repo_service "code.gitea.io/gitea/services/repository" + + "xorm.io/builder" +) + +// NewTeam creates a record of new team. +// It's caller's responsibility to assign organization ID. +func NewTeam(ctx context.Context, t *organization.Team) (err error) { + if len(t.Name) == 0 { + return util.NewInvalidArgumentErrorf("empty team name") + } + + if err = organization.IsUsableTeamName(t.Name); err != nil { + return err + } + + has, err := db.ExistByID[user_model.User](ctx, t.OrgID) + if err != nil { + return err + } + if !has { + return organization.ErrOrgNotExist{ID: t.OrgID} + } + + t.LowerName = strings.ToLower(t.Name) + has, err = db.Exist[organization.Team](ctx, builder.Eq{ + "org_id": t.OrgID, + "lower_name": t.LowerName, + }) + if err != nil { + return err + } + if has { + return organization.ErrTeamAlreadyExist{OrgID: t.OrgID, Name: t.LowerName} + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if err = db.Insert(ctx, t); err != nil { + return err + } + + // insert units for team + if len(t.Units) > 0 { + for _, unit := range t.Units { + unit.TeamID = t.ID + } + if err = db.Insert(ctx, &t.Units); err != nil { + return err + } + } + + // Add all repositories to the team if it has access to all of them. + if t.IncludesAllRepositories { + err = repo_service.AddAllRepositoriesToTeam(ctx, t) + if err != nil { + return fmt.Errorf("addAllRepositories: %w", err) + } + } + + // Update organization number of teams. + if _, err = db.Exec(ctx, "UPDATE `user` SET num_teams=num_teams+1 WHERE id = ?", t.OrgID); err != nil { + return err + } + return committer.Commit() +} + +// UpdateTeam updates information of team. +func UpdateTeam(ctx context.Context, t *organization.Team, authChanged, includeAllChanged bool) (err error) { + if len(t.Name) == 0 { + return util.NewInvalidArgumentErrorf("empty team name") + } + + if len(t.Description) > 255 { + t.Description = t.Description[:255] + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + t.LowerName = strings.ToLower(t.Name) + has, err := db.Exist[organization.Team](ctx, builder.Eq{ + "org_id": t.OrgID, + "lower_name": t.LowerName, + }.And(builder.Neq{"id": t.ID}), + ) + if err != nil { + return err + } else if has { + return organization.ErrTeamAlreadyExist{OrgID: t.OrgID, Name: t.LowerName} + } + + sess := db.GetEngine(ctx) + if _, err = sess.ID(t.ID).Cols("name", "lower_name", "description", + "can_create_org_repo", "authorize", "includes_all_repositories").Update(t); err != nil { + return fmt.Errorf("update: %w", err) + } + + // update units for team + if len(t.Units) > 0 { + for _, unit := range t.Units { + unit.TeamID = t.ID + } + // Delete team-unit. + if _, err := sess. + Where("team_id=?", t.ID). + Delete(new(organization.TeamUnit)); err != nil { + return err + } + if _, err = sess.Cols("org_id", "team_id", "type", "access_mode").Insert(&t.Units); err != nil { + return err + } + } + + // Update access for team members if needed. + if authChanged { + if err = t.LoadRepositories(ctx); err != nil { + return fmt.Errorf("LoadRepositories: %w", err) + } + + for _, repo := range t.Repos { + if err = access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil { + return fmt.Errorf("recalculateTeamAccesses: %w", err) + } + } + } + + // Add all repositories to the team if it has access to all of them. + if includeAllChanged && t.IncludesAllRepositories { + err = repo_service.AddAllRepositoriesToTeam(ctx, t) + if err != nil { + return fmt.Errorf("addAllRepositories: %w", err) + } + } + + return committer.Commit() +} + +// AddTeamMember adds new membership of given team to given organization, +// the user will have membership to given organization automatically when needed. +func AddTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error { + if user_model.IsUserBlockedBy(ctx, user, team.OrgID) { + return user_model.ErrBlockedUser + } + + isAlreadyMember, err := organization.IsTeamMember(ctx, team.OrgID, team.ID, user.ID) + if err != nil || isAlreadyMember { + return err + } + + if err := organization.AddOrgUser(ctx, team.OrgID, user.ID); err != nil { + return err + } + + err = db.WithTx(ctx, func(ctx context.Context) error { + // check in transaction + isAlreadyMember, err = organization.IsTeamMember(ctx, team.OrgID, team.ID, user.ID) + if err != nil || isAlreadyMember { + return err + } + + sess := db.GetEngine(ctx) + + if err := db.Insert(ctx, &organization.TeamUser{ + UID: user.ID, + OrgID: team.OrgID, + TeamID: team.ID, + }); err != nil { + return err + } else if _, err := sess.Incr("num_members").ID(team.ID).Update(new(organization.Team)); err != nil { + return err + } + + team.NumMembers++ + + // Give access to team repositories. + // update exist access if mode become bigger + subQuery := builder.Select("repo_id").From("team_repo"). + Where(builder.Eq{"team_id": team.ID}) + + if _, err := sess.Where("user_id=?", user.ID). + In("repo_id", subQuery). + And("mode < ?", team.AccessMode). + SetExpr("mode", team.AccessMode). + Update(new(access_model.Access)); err != nil { + return fmt.Errorf("update user accesses: %w", err) + } + + // for not exist access + var repoIDs []int64 + accessSubQuery := builder.Select("repo_id").From("access").Where(builder.Eq{"user_id": user.ID}) + if err := sess.SQL(subQuery.And(builder.NotIn("repo_id", accessSubQuery))).Find(&repoIDs); err != nil { + return fmt.Errorf("select id accesses: %w", err) + } + + accesses := make([]*access_model.Access, 0, 100) + for i, repoID := range repoIDs { + accesses = append(accesses, &access_model.Access{RepoID: repoID, UserID: user.ID, Mode: team.AccessMode}) + if (i%100 == 0 || i == len(repoIDs)-1) && len(accesses) > 0 { + if err = db.Insert(ctx, accesses); err != nil { + return fmt.Errorf("insert new user accesses: %w", err) + } + accesses = accesses[:0] + } + } + return nil + }) + if err != nil { + return err + } + + // this behaviour may spend much time so run it in a goroutine + // FIXME: Update watch repos batchly + if setting.Service.AutoWatchNewRepos { + // Get team and its repositories. + if err := team.LoadRepositories(ctx); err != nil { + log.Error("team.LoadRepositories failed: %v", err) + } + + // FIXME: in the goroutine, it can't access the "ctx", it could only use db.DefaultContext at the moment + go func(repos []*repo_model.Repository) { + for _, repo := range repos { + if err = repo_model.WatchRepo(db.DefaultContext, user, repo, true); err != nil { + log.Error("watch repo failed: %v", err) + } + } + }(team.Repos) + } + + return nil +} + +// DeleteTeam deletes given team. +// It's caller's responsibility to assign organization ID. +func DeleteTeam(ctx context.Context, t *organization.Team) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if err := t.LoadRepositories(ctx); err != nil { + return err + } + + if err := t.LoadMembers(ctx); err != nil { + return err + } + + // update branch protections + { + protections := make([]*git_model.ProtectedBranch, 0, 10) + err := db.GetEngine(ctx).In("repo_id", + builder.Select("id").From("repository").Where(builder.Eq{"owner_id": t.OrgID})). + Find(&protections) + if err != nil { + return fmt.Errorf("findProtectedBranches: %w", err) + } + for _, p := range protections { + if err := git_model.RemoveTeamIDFromProtectedBranch(ctx, p, t.ID); err != nil { + return err + } + } + } + + if err := repo_service.RemoveAllRepositoriesFromTeam(ctx, t); err != nil { + return err + } + + if err := db.DeleteBeans(ctx, + &organization.Team{ID: t.ID}, + &organization.TeamUser{OrgID: t.OrgID, TeamID: t.ID}, + &organization.TeamUnit{TeamID: t.ID}, + &organization.TeamInvite{TeamID: t.ID}, + &issues_model.Review{Type: issues_model.ReviewTypeRequest, ReviewerTeamID: t.ID}, // batch delete the binding relationship between team and PR (request review from team) + ); err != nil { + return err + } + + for _, tm := range t.Members { + if err := removeInvalidOrgUser(ctx, t.OrgID, tm); err != nil { + return err + } + } + + // Update organization number of teams. + if _, err := db.Exec(ctx, "UPDATE `user` SET num_teams=num_teams-1 WHERE id=?", t.OrgID); err != nil { + return err + } + + return committer.Commit() +} + +// RemoveTeamMember removes member from given team of given organization. +func RemoveTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + if err := removeTeamMember(ctx, team, user); err != nil { + return err + } + return committer.Commit() +} + +func removeTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error { + e := db.GetEngine(ctx) + isMember, err := organization.IsTeamMember(ctx, team.OrgID, team.ID, user.ID) + if err != nil || !isMember { + return err + } + + // Check if the user to delete is the last member in owner team. + if team.IsOwnerTeam() && team.NumMembers == 1 { + return organization.ErrLastOrgOwner{UID: user.ID} + } + + team.NumMembers-- + + if err := team.LoadRepositories(ctx); err != nil { + return err + } + + if _, err := e.Delete(&organization.TeamUser{ + UID: user.ID, + OrgID: team.OrgID, + TeamID: team.ID, + }); err != nil { + return err + } else if _, err = e. + ID(team.ID). + Cols("num_members"). + Update(team); err != nil { + return err + } + + // Delete access to team repositories. + for _, repo := range team.Repos { + if err := access_model.RecalculateUserAccess(ctx, repo, user.ID); err != nil { + return err + } + + // Remove watches from now unaccessible + if err := models.ReconsiderWatches(ctx, repo, user); err != nil { + return err + } + + // Remove issue assignments from now unaccessible + if err := models.ReconsiderRepoIssuesAssignee(ctx, repo, user); err != nil { + return err + } + } + + return removeInvalidOrgUser(ctx, team.OrgID, user) +} + +func removeInvalidOrgUser(ctx context.Context, orgID int64, user *user_model.User) error { + // Check if the user is a member of any team in the organization. + if count, err := db.GetEngine(ctx).Count(&organization.TeamUser{ + UID: user.ID, + OrgID: orgID, + }); err != nil { + return err + } else if count == 0 { + org, err := organization.GetOrgByID(ctx, orgID) + if err != nil { + return err + } + + return RemoveOrgUser(ctx, org, user) + } + return nil +} diff --git a/models/org_team_test.go b/services/org/team_test.go similarity index 61% rename from models/org_team_test.go rename to services/org/team_test.go index cf2c8be536..d117013b10 100644 --- a/models/org_team_test.go +++ b/services/org/team_test.go @@ -1,9 +1,10 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package models +package org import ( + "fmt" "strings" "testing" @@ -14,6 +15,8 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/structs" + repo_service "code.gitea.io/gitea/services/repository" "github.com/stretchr/testify/assert" ) @@ -60,11 +63,6 @@ func TestTeam_RemoveMember(t *testing.T) { assert.True(t, organization.IsErrLastOrgOwner(err)) } -func TestIsUsableTeamName(t *testing.T) { - assert.NoError(t, organization.IsUsableTeamName("usable")) - assert.True(t, db.IsErrNameReserved(organization.IsUsableTeamName("new"))) -} - func TestNewTeam(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) @@ -185,3 +183,132 @@ func TestRepository_RecalculateAccesses3(t *testing.T) { assert.NoError(t, err) assert.True(t, has) } + +func TestIncludesAllRepositoriesTeams(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + testTeamRepositories := func(teamID int64, repoIDs []int64) { + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) + assert.NoError(t, team.LoadRepositories(db.DefaultContext), "%s: GetRepositories", team.Name) + assert.Len(t, team.Repos, team.NumRepos, "%s: len repo", team.Name) + assert.Len(t, team.Repos, len(repoIDs), "%s: repo count", team.Name) + for i, rid := range repoIDs { + if rid > 0 { + assert.True(t, repo_service.HasRepository(db.DefaultContext, team, rid), "%s: HasRepository(%d) %d", rid, i) + } + } + } + + // Get an admin user. + user, err := user_model.GetUserByID(db.DefaultContext, 1) + assert.NoError(t, err, "GetUserByID") + + // Create org. + org := &organization.Organization{ + Name: "All_repo", + IsActive: true, + Type: user_model.UserTypeOrganization, + Visibility: structs.VisibleTypePublic, + } + assert.NoError(t, organization.CreateOrganization(db.DefaultContext, org, user), "CreateOrganization") + + // Check Owner team. + ownerTeam, err := org.GetOwnerTeam(db.DefaultContext) + assert.NoError(t, err, "GetOwnerTeam") + assert.True(t, ownerTeam.IncludesAllRepositories, "Owner team includes all repositories") + + // Create repos. + repoIDs := make([]int64, 0) + for i := 0; i < 3; i++ { + r, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), repo_service.CreateRepoOptions{Name: fmt.Sprintf("repo-%d", i)}) + assert.NoError(t, err, "CreateRepository %d", i) + if r != nil { + repoIDs = append(repoIDs, r.ID) + } + } + // Get fresh copy of Owner team after creating repos. + ownerTeam, err = org.GetOwnerTeam(db.DefaultContext) + assert.NoError(t, err, "GetOwnerTeam") + + // Create teams and check repositories. + teams := []*organization.Team{ + ownerTeam, + { + OrgID: org.ID, + Name: "team one", + AccessMode: perm.AccessModeRead, + IncludesAllRepositories: true, + }, + { + OrgID: org.ID, + Name: "team 2", + AccessMode: perm.AccessModeRead, + IncludesAllRepositories: false, + }, + { + OrgID: org.ID, + Name: "team three", + AccessMode: perm.AccessModeWrite, + IncludesAllRepositories: true, + }, + { + OrgID: org.ID, + Name: "team 4", + AccessMode: perm.AccessModeWrite, + IncludesAllRepositories: false, + }, + } + teamRepos := [][]int64{ + repoIDs, + repoIDs, + {}, + repoIDs, + {}, + } + for i, team := range teams { + if i > 0 { // first team is Owner. + assert.NoError(t, NewTeam(db.DefaultContext, team), "%s: NewTeam", team.Name) + } + testTeamRepositories(team.ID, teamRepos[i]) + } + + // Update teams and check repositories. + teams[3].IncludesAllRepositories = false + teams[4].IncludesAllRepositories = true + teamRepos[4] = repoIDs + for i, team := range teams { + assert.NoError(t, UpdateTeam(db.DefaultContext, team, false, true), "%s: UpdateTeam", team.Name) + testTeamRepositories(team.ID, teamRepos[i]) + } + + // Create repo and check teams repositories. + r, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), repo_service.CreateRepoOptions{Name: "repo-last"}) + assert.NoError(t, err, "CreateRepository last") + if r != nil { + repoIDs = append(repoIDs, r.ID) + } + teamRepos[0] = repoIDs + teamRepos[1] = repoIDs + teamRepos[4] = repoIDs + for i, team := range teams { + testTeamRepositories(team.ID, teamRepos[i]) + } + + // Remove repo and check teams repositories. + assert.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, repoIDs[0]), "DeleteRepository") + teamRepos[0] = repoIDs[1:] + teamRepos[1] = repoIDs[1:] + teamRepos[3] = repoIDs[1:3] + teamRepos[4] = repoIDs[1:] + for i, team := range teams { + testTeamRepositories(team.ID, teamRepos[i]) + } + + // Wipe created items. + for i, rid := range repoIDs { + if i > 0 { // first repo already deleted. + assert.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, rid), "DeleteRepository %d", i) + } + } + assert.NoError(t, organization.DeleteOrganization(db.DefaultContext, org), "DeleteOrganization") +} diff --git a/models/org.go b/services/org/user.go similarity index 99% rename from models/org.go rename to services/org/user.go index 69cc47137e..0627860fe7 100644 --- a/models/org.go +++ b/services/org/user.go @@ -2,7 +2,7 @@ // Copyright 2019 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package models +package org import ( "context" diff --git a/models/org_test.go b/services/org/user_test.go similarity index 99% rename from models/org_test.go rename to services/org/user_test.go index 247530406d..56d01a3b63 100644 --- a/models/org_test.go +++ b/services/org/user_test.go @@ -1,7 +1,7 @@ // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package models +package org import ( "testing" diff --git a/services/repository/create.go b/services/repository/create.go index 0207f12a33..14e625d962 100644 --- a/services/repository/create.go +++ b/services/repository/create.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" @@ -448,7 +447,7 @@ func CreateRepositoryByExample(ctx context.Context, doer, u *user_model.User, re } for _, t := range teams { if t.IncludesAllRepositories { - if err := models.AddRepository(ctx, t, repo); err != nil { + if err := addRepositoryToTeam(ctx, t, repo); err != nil { return fmt.Errorf("AddRepository: %w", err) } } diff --git a/services/repository/create_test.go b/services/repository/create_test.go deleted file mode 100644 index 41e6b615db..0000000000 --- a/services/repository/create_test.go +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repository - -import ( - "fmt" - "testing" - - "code.gitea.io/gitea/models" - "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/organization" - "code.gitea.io/gitea/models/perm" - "code.gitea.io/gitea/models/unittest" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/structs" - - "github.com/stretchr/testify/assert" -) - -func TestIncludesAllRepositoriesTeams(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - - testTeamRepositories := func(teamID int64, repoIDs []int64) { - team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) - assert.NoError(t, team.LoadRepositories(db.DefaultContext), "%s: GetRepositories", team.Name) - assert.Len(t, team.Repos, team.NumRepos, "%s: len repo", team.Name) - assert.Len(t, team.Repos, len(repoIDs), "%s: repo count", team.Name) - for i, rid := range repoIDs { - if rid > 0 { - assert.True(t, HasRepository(db.DefaultContext, team, rid), "%s: HasRepository(%d) %d", rid, i) - } - } - } - - // Get an admin user. - user, err := user_model.GetUserByID(db.DefaultContext, 1) - assert.NoError(t, err, "GetUserByID") - - // Create org. - org := &organization.Organization{ - Name: "All_repo", - IsActive: true, - Type: user_model.UserTypeOrganization, - Visibility: structs.VisibleTypePublic, - } - assert.NoError(t, organization.CreateOrganization(db.DefaultContext, org, user), "CreateOrganization") - - // Check Owner team. - ownerTeam, err := org.GetOwnerTeam(db.DefaultContext) - assert.NoError(t, err, "GetOwnerTeam") - assert.True(t, ownerTeam.IncludesAllRepositories, "Owner team includes all repositories") - - // Create repos. - repoIDs := make([]int64, 0) - for i := 0; i < 3; i++ { - r, err := CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), CreateRepoOptions{Name: fmt.Sprintf("repo-%d", i)}) - assert.NoError(t, err, "CreateRepository %d", i) - if r != nil { - repoIDs = append(repoIDs, r.ID) - } - } - // Get fresh copy of Owner team after creating repos. - ownerTeam, err = org.GetOwnerTeam(db.DefaultContext) - assert.NoError(t, err, "GetOwnerTeam") - - // Create teams and check repositories. - teams := []*organization.Team{ - ownerTeam, - { - OrgID: org.ID, - Name: "team one", - AccessMode: perm.AccessModeRead, - IncludesAllRepositories: true, - }, - { - OrgID: org.ID, - Name: "team 2", - AccessMode: perm.AccessModeRead, - IncludesAllRepositories: false, - }, - { - OrgID: org.ID, - Name: "team three", - AccessMode: perm.AccessModeWrite, - IncludesAllRepositories: true, - }, - { - OrgID: org.ID, - Name: "team 4", - AccessMode: perm.AccessModeWrite, - IncludesAllRepositories: false, - }, - } - teamRepos := [][]int64{ - repoIDs, - repoIDs, - {}, - repoIDs, - {}, - } - for i, team := range teams { - if i > 0 { // first team is Owner. - assert.NoError(t, models.NewTeam(db.DefaultContext, team), "%s: NewTeam", team.Name) - } - testTeamRepositories(team.ID, teamRepos[i]) - } - - // Update teams and check repositories. - teams[3].IncludesAllRepositories = false - teams[4].IncludesAllRepositories = true - teamRepos[4] = repoIDs - for i, team := range teams { - assert.NoError(t, models.UpdateTeam(db.DefaultContext, team, false, true), "%s: UpdateTeam", team.Name) - testTeamRepositories(team.ID, teamRepos[i]) - } - - // Create repo and check teams repositories. - r, err := CreateRepositoryDirectly(db.DefaultContext, user, org.AsUser(), CreateRepoOptions{Name: "repo-last"}) - assert.NoError(t, err, "CreateRepository last") - if r != nil { - repoIDs = append(repoIDs, r.ID) - } - teamRepos[0] = repoIDs - teamRepos[1] = repoIDs - teamRepos[4] = repoIDs - for i, team := range teams { - testTeamRepositories(team.ID, teamRepos[i]) - } - - // Remove repo and check teams repositories. - assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, repoIDs[0]), "DeleteRepository") - teamRepos[0] = repoIDs[1:] - teamRepos[1] = repoIDs[1:] - teamRepos[3] = repoIDs[1:3] - teamRepos[4] = repoIDs[1:] - for i, team := range teams { - testTeamRepositories(team.ID, teamRepos[i]) - } - - // Wipe created items. - for i, rid := range repoIDs { - if i > 0 { // first repo already deleted. - assert.NoError(t, DeleteRepositoryDirectly(db.DefaultContext, user, rid), "DeleteRepository %d", i) - } - } - assert.NoError(t, organization.DeleteOrganization(db.DefaultContext, org), "DeleteOrganization") -} diff --git a/services/repository/delete.go b/services/repository/delete.go index e580833140..f33bae7790 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -348,87 +348,6 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, repoID return nil } -// removeRepositoryFromTeam removes a repository from a team and recalculates access -// Note: Repository shall not be removed from team if it includes all repositories (unless the repository is deleted) -func removeRepositoryFromTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository, recalculate bool) (err error) { - e := db.GetEngine(ctx) - if err = organization.RemoveTeamRepo(ctx, t.ID, repo.ID); err != nil { - return err - } - - t.NumRepos-- - if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil { - return err - } - - // Don't need to recalculate when delete a repository from organization. - if recalculate { - if err = access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil { - return err - } - } - - teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{ - TeamID: t.ID, - }) - if err != nil { - return fmt.Errorf("GetTeamMembers: %w", err) - } - for _, member := range teamMembers { - has, err := access_model.HasAnyUnitAccess(ctx, member.ID, repo) - if err != nil { - return err - } else if has { - continue - } - - if err = repo_model.WatchRepo(ctx, member, repo, false); err != nil { - return err - } - - // Remove all IssueWatches a user has subscribed to in the repositories - if err := issues_model.RemoveIssueWatchersByRepoID(ctx, member.ID, repo.ID); err != nil { - return err - } - } - - return nil -} - -// HasRepository returns true if given repository belong to team. -func HasRepository(ctx context.Context, t *organization.Team, repoID int64) bool { - return organization.HasTeamRepo(ctx, t.OrgID, t.ID, repoID) -} - -// RemoveRepositoryFromTeam removes repository from team of organization. -// If the team shall include all repositories the request is ignored. -func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID int64) error { - if !HasRepository(ctx, t, repoID) { - return nil - } - - if t.IncludesAllRepositories { - return nil - } - - repo, err := repo_model.GetRepositoryByID(ctx, repoID) - if err != nil { - return err - } - - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() - - if err = removeRepositoryFromTeam(ctx, t, repo, true); err != nil { - return err - } - - return committer.Commit() -} - // DeleteOwnerRepositoriesDirectly calls DeleteRepositoryDirectly for all repos of the given owner func DeleteOwnerRepositoriesDirectly(ctx context.Context, owner *user_model.User) error { for { diff --git a/services/repository/repo_team.go b/services/repository/repo_team.go new file mode 100644 index 0000000000..29c67893b2 --- /dev/null +++ b/services/repository/repo_team.go @@ -0,0 +1,226 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "context" + "errors" + "fmt" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/setting" +) + +// TeamAddRepository adds new repository to team of organization. +func TeamAddRepository(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) { + if repo.OwnerID != t.OrgID { + return errors.New("repository does not belong to organization") + } else if organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) { + return nil + } + + return db.WithTx(ctx, func(ctx context.Context) error { + return addRepositoryToTeam(ctx, t, repo) + }) +} + +func addRepositoryToTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) { + if err = organization.AddTeamRepo(ctx, t.OrgID, t.ID, repo.ID); err != nil { + return err + } + + if err = organization.IncrTeamRepoNum(ctx, t.ID); err != nil { + return fmt.Errorf("update team: %w", err) + } + + t.NumRepos++ + + if err = access_model.RecalculateTeamAccesses(ctx, repo, 0); err != nil { + return fmt.Errorf("recalculateAccesses: %w", err) + } + + // Make all team members watch this repo if enabled in global settings + if setting.Service.AutoWatchNewRepos { + if err = t.LoadMembers(ctx); err != nil { + return fmt.Errorf("getMembers: %w", err) + } + for _, u := range t.Members { + if err = repo_model.WatchRepo(ctx, u, repo, true); err != nil { + return fmt.Errorf("watchRepo: %w", err) + } + } + } + + return nil +} + +// AddAllRepositoriesToTeam adds all repositories to the team. +// If the team already has some repositories they will be left unchanged. +func AddAllRepositoriesToTeam(ctx context.Context, t *organization.Team) error { + return db.WithTx(ctx, func(ctx context.Context) error { + orgRepos, err := organization.GetOrgRepositories(ctx, t.OrgID) + if err != nil { + return fmt.Errorf("get org repos: %w", err) + } + + for _, repo := range orgRepos { + if !organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) { + if err := addRepositoryToTeam(ctx, t, repo); err != nil { + return fmt.Errorf("AddRepository: %w", err) + } + } + } + + return nil + }) +} + +// RemoveAllRepositoriesFromTeam removes all repositories from team and recalculates access +func RemoveAllRepositoriesFromTeam(ctx context.Context, t *organization.Team) (err error) { + if t.IncludesAllRepositories { + return nil + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if err = removeAllRepositoriesFromTeam(ctx, t); err != nil { + return err + } + + return committer.Commit() +} + +// removeAllRepositoriesFromTeam removes all repositories from team and recalculates access +// Note: Shall not be called if team includes all repositories +func removeAllRepositoriesFromTeam(ctx context.Context, t *organization.Team) (err error) { + e := db.GetEngine(ctx) + // Delete all accesses. + for _, repo := range t.Repos { + if err := access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil { + return err + } + + // Remove watches from all users and now unaccessible repos + for _, user := range t.Members { + has, err := access_model.HasAnyUnitAccess(ctx, user.ID, repo) + if err != nil { + return err + } else if has { + continue + } + + if err = repo_model.WatchRepo(ctx, user, repo, false); err != nil { + return err + } + + // Remove all IssueWatches a user has subscribed to in the repositories + if err = issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID); err != nil { + return err + } + } + } + + // Delete team-repo + if _, err := e. + Where("team_id=?", t.ID). + Delete(new(organization.TeamRepo)); err != nil { + return err + } + + t.NumRepos = 0 + if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil { + return err + } + + return nil +} + +// RemoveRepositoryFromTeam removes repository from team of organization. +// If the team shall include all repositories the request is ignored. +func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID int64) error { + if !HasRepository(ctx, t, repoID) { + return nil + } + + if t.IncludesAllRepositories { + return nil + } + + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + return err + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if err = removeRepositoryFromTeam(ctx, t, repo, true); err != nil { + return err + } + + return committer.Commit() +} + +// removeRepositoryFromTeam removes a repository from a team and recalculates access +// Note: Repository shall not be removed from team if it includes all repositories (unless the repository is deleted) +func removeRepositoryFromTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository, recalculate bool) (err error) { + e := db.GetEngine(ctx) + if err = organization.RemoveTeamRepo(ctx, t.ID, repo.ID); err != nil { + return err + } + + t.NumRepos-- + if _, err = e.ID(t.ID).Cols("num_repos").Update(t); err != nil { + return err + } + + // Don't need to recalculate when delete a repository from organization. + if recalculate { + if err = access_model.RecalculateTeamAccesses(ctx, repo, t.ID); err != nil { + return err + } + } + + teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{ + TeamID: t.ID, + }) + if err != nil { + return fmt.Errorf("GetTeamMembers: %w", err) + } + for _, member := range teamMembers { + has, err := access_model.HasAnyUnitAccess(ctx, member.ID, repo) + if err != nil { + return err + } else if has { + continue + } + + if err = repo_model.WatchRepo(ctx, member, repo, false); err != nil { + return err + } + + // Remove all IssueWatches a user has subscribed to in the repositories + if err := issues_model.RemoveIssueWatchersByRepoID(ctx, member.ID, repo.ID); err != nil { + return err + } + } + + return nil +} + +// HasRepository returns true if given repository belong to team. +func HasRepository(ctx context.Context, t *organization.Team, repoID int64) bool { + return organization.HasTeamRepo(ctx, t.OrgID, t.ID, repoID) +} diff --git a/services/org/repo_test.go b/services/repository/repo_team_test.go similarity index 98% rename from services/org/repo_test.go rename to services/repository/repo_team_test.go index 68c64a01ab..70b1b47d0a 100644 --- a/services/org/repo_test.go +++ b/services/repository/repo_team_test.go @@ -1,7 +1,7 @@ // Copyright 2021 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package org +package repository import ( "testing" diff --git a/services/repository/transfer.go b/services/repository/transfer.go index 301d895337..9a643469d9 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -59,7 +59,7 @@ func TransferOwnership(ctx context.Context, doer, newOwner *user_model.User, rep } for _, team := range teams { - if err := models.AddRepository(ctx, team, newRepo); err != nil { + if err := addRepositoryToTeam(ctx, team, newRepo); err != nil { return err } } @@ -205,7 +205,7 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName } for _, t := range teams { if t.IncludesAllRepositories { - if err := models.AddRepository(ctx, t, repo); err != nil { + if err := addRepositoryToTeam(ctx, t, repo); err != nil { return fmt.Errorf("AddRepository: %w", err) } } diff --git a/services/user/user.go b/services/user/user.go index 7855dbb78b..7bde642412 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -188,7 +188,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { break } for _, org := range orgs { - if err := models.RemoveOrgUser(ctx, org, u); err != nil { + if err := org_service.RemoveOrgUser(ctx, org, u); err != nil { if organization.IsErrLastOrgOwner(err) { err = org_service.DeleteOrganization(ctx, org, true) if err != nil { diff --git a/services/user/user_test.go b/services/user/user_test.go index efcbc669c8..c668b005c5 100644 --- a/services/user/user_test.go +++ b/services/user/user_test.go @@ -18,6 +18,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" + org_service "code.gitea.io/gitea/services/org" "github.com/stretchr/testify/assert" ) @@ -44,7 +45,7 @@ func TestDeleteUser(t *testing.T) { assert.NoError(t, db.GetEngine(db.DefaultContext).Find(&orgUsers, &organization.OrgUser{UID: userID})) for _, orgUser := range orgUsers { org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: orgUser.OrgID}) - if err := models.RemoveOrgUser(db.DefaultContext, org, user); err != nil { + if err := org_service.RemoveOrgUser(db.DefaultContext, org, user); err != nil { assert.True(t, organization.IsErrLastOrgOwner(err)) return } diff --git a/tests/integration/auth_ldap_test.go b/tests/integration/auth_ldap_test.go index 8c8b6b02d1..00ef72c1c3 100644 --- a/tests/integration/auth_ldap_test.go +++ b/tests/integration/auth_ldap_test.go @@ -10,7 +10,6 @@ import ( "strings" "testing" - "code.gitea.io/gitea/models" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" @@ -19,6 +18,7 @@ import ( "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/ldap" + org_service "code.gitea.io/gitea/services/org" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" @@ -429,9 +429,9 @@ func TestLDAPGroupTeamSyncAddMember(t *testing.T) { isMember, err := organization.IsTeamMember(db.DefaultContext, usersOrgs[0].ID, team.ID, user.ID) assert.NoError(t, err) assert.True(t, isMember, "Membership should be added to the right team") - err = models.RemoveTeamMember(db.DefaultContext, team, user) + err = org_service.RemoveTeamMember(db.DefaultContext, team, user) assert.NoError(t, err) - err = models.RemoveOrgUser(db.DefaultContext, usersOrgs[0], user) + err = org_service.RemoveOrgUser(db.DefaultContext, usersOrgs[0], user) assert.NoError(t, err) } else { // assert members of LDAP group "cn=admin_staff" keep initial team membership since mapped team does not exist @@ -461,7 +461,7 @@ func TestLDAPGroupTeamSyncRemoveMember(t *testing.T) { }) err = organization.AddOrgUser(db.DefaultContext, org.ID, user.ID) assert.NoError(t, err) - err = models.AddTeamMember(db.DefaultContext, team, user) + err = org_service.AddTeamMember(db.DefaultContext, team, user) assert.NoError(t, err) isMember, err := organization.IsOrganizationMember(db.DefaultContext, org.ID, user.ID) assert.NoError(t, err)