diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /models/organization | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r-- | models/organization/TestInconsistentOwnerTeam/team.yml | 10 | ||||
-rw-r--r-- | models/organization/TestInconsistentOwnerTeam/team_unit.yml | 59 | ||||
-rw-r--r-- | models/organization/main_test.go | 21 | ||||
-rw-r--r-- | models/organization/mini_org.go | 78 | ||||
-rw-r--r-- | models/organization/org.go | 833 | ||||
-rw-r--r-- | models/organization/org_repo.go | 17 | ||||
-rw-r--r-- | models/organization/org_test.go | 548 | ||||
-rw-r--r-- | models/organization/org_user.go | 138 | ||||
-rw-r--r-- | models/organization/org_user_test.go | 155 | ||||
-rw-r--r-- | models/organization/team.go | 310 | ||||
-rw-r--r-- | models/organization/team_invite.go | 161 | ||||
-rw-r--r-- | models/organization/team_invite_test.go | 49 | ||||
-rw-r--r-- | models/organization/team_list.go | 128 | ||||
-rw-r--r-- | models/organization/team_repo.go | 85 | ||||
-rw-r--r-- | models/organization/team_test.go | 250 | ||||
-rw-r--r-- | models/organization/team_unit.go | 51 | ||||
-rw-r--r-- | models/organization/team_user.go | 89 |
17 files changed, 2982 insertions, 0 deletions
diff --git a/models/organization/TestInconsistentOwnerTeam/team.yml b/models/organization/TestInconsistentOwnerTeam/team.yml new file mode 100644 index 0000000..90e3ad4 --- /dev/null +++ b/models/organization/TestInconsistentOwnerTeam/team.yml @@ -0,0 +1,10 @@ +- + id: 1000 + org_id: 1000 + lower_name: owners + name: Owners + authorize: 4 # owner + num_repos: 0 + num_members: 0 + includes_all_repositories: true + can_create_org_repo: true diff --git a/models/organization/TestInconsistentOwnerTeam/team_unit.yml b/models/organization/TestInconsistentOwnerTeam/team_unit.yml new file mode 100644 index 0000000..91e03d6 --- /dev/null +++ b/models/organization/TestInconsistentOwnerTeam/team_unit.yml @@ -0,0 +1,59 @@ +- + id: 1000 + team_id: 1000 + type: 1 + access_mode: 0 # None + +- + id: 1001 + team_id: 1000 + type: 2 + access_mode: 0 + +- + id: 1002 + team_id: 1000 + type: 3 + access_mode: 0 + +- + id: 1003 + team_id: 1000 + type: 4 + access_mode: 0 + +- + id: 1004 + team_id: 1000 + type: 5 + access_mode: 0 + +- + id: 1005 + team_id: 1000 + type: 6 + access_mode: 0 + +- + id: 1006 + team_id: 1000 + type: 7 + access_mode: 0 + +- + id: 1007 + team_id: 1000 + type: 8 + access_mode: 0 + +- + id: 1008 + team_id: 1000 + type: 9 + access_mode: 0 + +- + id: 1009 + team_id: 1000 + type: 10 + access_mode: 0 diff --git a/models/organization/main_test.go b/models/organization/main_test.go new file mode 100644 index 0000000..c35898a --- /dev/null +++ b/models/organization/main_test.go @@ -0,0 +1,21 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization_test + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + + _ "code.gitea.io/gitea/models" + _ "code.gitea.io/gitea/models/actions" + _ "code.gitea.io/gitea/models/activities" + _ "code.gitea.io/gitea/models/organization" + _ "code.gitea.io/gitea/models/repo" + _ "code.gitea.io/gitea/models/user" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/models/organization/mini_org.go b/models/organization/mini_org.go new file mode 100644 index 0000000..b1b2462 --- /dev/null +++ b/models/organization/mini_org.go @@ -0,0 +1,78 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + + "xorm.io/builder" +) + +// MinimalOrg represents a simple organization with only the needed columns +type MinimalOrg = Organization + +// GetUserOrgsList returns all organizations the given user has access to +func GetUserOrgsList(ctx context.Context, user *user_model.User) ([]*MinimalOrg, error) { + schema, err := db.TableInfo(new(user_model.User)) + if err != nil { + return nil, err + } + + outputCols := []string{ + "id", + "name", + "full_name", + "visibility", + "avatar", + "avatar_email", + "use_custom_avatar", + } + + groupByCols := &strings.Builder{} + for _, col := range outputCols { + fmt.Fprintf(groupByCols, "`%s`.%s,", schema.Name, col) + } + groupByStr := groupByCols.String() + groupByStr = groupByStr[0 : len(groupByStr)-1] + + sess := db.GetEngine(ctx) + sess = sess.Select(groupByStr+", count(distinct repo_id) as org_count"). + Table("user"). + Join("INNER", "team", "`team`.org_id = `user`.id"). + Join("INNER", "team_user", "`team`.id = `team_user`.team_id"). + Join("LEFT", builder. + Select("id as repo_id, owner_id as repo_owner_id"). + From("repository"). + Where(repo_model.AccessibleRepositoryCondition(user, unit.TypeInvalid)), "`repository`.repo_owner_id = `team`.org_id"). + Where("`team_user`.uid = ?", user.ID). + GroupBy(groupByStr) + + type OrgCount struct { + Organization `xorm:"extends"` + OrgCount int + } + + orgCounts := make([]*OrgCount, 0, 10) + + if err := sess. + Asc("`user`.name"). + Find(&orgCounts); err != nil { + return nil, err + } + + orgs := make([]*MinimalOrg, len(orgCounts)) + for i, orgCount := range orgCounts { + orgCount.Organization.NumRepos = orgCount.OrgCount + orgs[i] = &orgCount.Organization + } + + return orgs, nil +} diff --git a/models/organization/org.go b/models/organization/org.go new file mode 100644 index 0000000..379c30b --- /dev/null +++ b/models/organization/org.go @@ -0,0 +1,833 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "context" + "fmt" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + secret_model "code.gitea.io/gitea/models/secret" + "code.gitea.io/gitea/models/unit" + 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/structs" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// ________ .__ __ .__ +// \_____ \_______ _________ ____ |__|____________ _/ |_|__| ____ ____ +// / | \_ __ \/ ___\__ \ / \| \___ /\__ \\ __\ |/ _ \ / \ +// / | \ | \/ /_/ > __ \| | \ |/ / / __ \| | | ( <_> ) | \ +// \_______ /__| \___ (____ /___| /__/_____ \(____ /__| |__|\____/|___| / +// \/ /_____/ \/ \/ \/ \/ \/ + +// ErrOrgNotExist represents a "OrgNotExist" kind of error. +type ErrOrgNotExist struct { + ID int64 + Name string +} + +// IsErrOrgNotExist checks if an error is a ErrOrgNotExist. +func IsErrOrgNotExist(err error) bool { + _, ok := err.(ErrOrgNotExist) + return ok +} + +func (err ErrOrgNotExist) Error() string { + return fmt.Sprintf("org does not exist [id: %d, name: %s]", err.ID, err.Name) +} + +func (err ErrOrgNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrLastOrgOwner represents a "LastOrgOwner" kind of error. +type ErrLastOrgOwner struct { + UID int64 +} + +// IsErrLastOrgOwner checks if an error is a ErrLastOrgOwner. +func IsErrLastOrgOwner(err error) bool { + _, ok := err.(ErrLastOrgOwner) + return ok +} + +func (err ErrLastOrgOwner) Error() string { + return fmt.Sprintf("user is the last member of owner team [uid: %d]", err.UID) +} + +// ErrUserNotAllowedCreateOrg represents a "UserNotAllowedCreateOrg" kind of error. +type ErrUserNotAllowedCreateOrg struct{} + +// IsErrUserNotAllowedCreateOrg checks if an error is an ErrUserNotAllowedCreateOrg. +func IsErrUserNotAllowedCreateOrg(err error) bool { + _, ok := err.(ErrUserNotAllowedCreateOrg) + return ok +} + +func (err ErrUserNotAllowedCreateOrg) Error() string { + return "user is not allowed to create organizations" +} + +func (err ErrUserNotAllowedCreateOrg) Unwrap() error { + return util.ErrPermissionDenied +} + +// Organization represents an organization +type Organization user_model.User + +// OrgFromUser converts user to organization +func OrgFromUser(user *user_model.User) *Organization { + return (*Organization)(user) +} + +// TableName represents the real table name of Organization +func (Organization) TableName() string { + return "user" +} + +// IsOwnedBy returns true if given user is in the owner team. +func (org *Organization) IsOwnedBy(ctx context.Context, uid int64) (bool, error) { + return IsOrganizationOwner(ctx, org.ID, uid) +} + +// IsOrgAdmin returns true if given user is in the owner team or an admin team. +func (org *Organization) IsOrgAdmin(ctx context.Context, uid int64) (bool, error) { + return IsOrganizationAdmin(ctx, org.ID, uid) +} + +// IsOrgMember returns true if given user is member of organization. +func (org *Organization) IsOrgMember(ctx context.Context, uid int64) (bool, error) { + return IsOrganizationMember(ctx, org.ID, uid) +} + +// CanCreateOrgRepo returns true if given user can create repo in organization +func (org *Organization) CanCreateOrgRepo(ctx context.Context, uid int64) (bool, error) { + return CanCreateOrgRepo(ctx, org.ID, uid) +} + +// GetTeam returns named team of organization. +func (org *Organization) GetTeam(ctx context.Context, name string) (*Team, error) { + return GetTeam(ctx, org.ID, name) +} + +// GetOwnerTeam returns owner team of organization. +func (org *Organization) GetOwnerTeam(ctx context.Context) (*Team, error) { + return org.GetTeam(ctx, OwnerTeamName) +} + +// FindOrgTeams returns all teams of a given organization +func FindOrgTeams(ctx context.Context, orgID int64) ([]*Team, error) { + var teams []*Team + return teams, db.GetEngine(ctx). + Where("org_id=?", orgID). + OrderBy("CASE WHEN name LIKE '" + OwnerTeamName + "' THEN '' ELSE name END"). + Find(&teams) +} + +// LoadTeams load teams if not loaded. +func (org *Organization) LoadTeams(ctx context.Context) ([]*Team, error) { + return FindOrgTeams(ctx, org.ID) +} + +// GetMembers returns all members of organization. +func (org *Organization) GetMembers(ctx context.Context) (user_model.UserList, map[int64]bool, error) { + return FindOrgMembers(ctx, &FindOrgMembersOpts{ + OrgID: org.ID, + }) +} + +// HasMemberWithUserID returns true if user with userID is part of the u organisation. +func (org *Organization) HasMemberWithUserID(ctx context.Context, userID int64) bool { + return org.hasMemberWithUserID(ctx, userID) +} + +func (org *Organization) hasMemberWithUserID(ctx context.Context, userID int64) bool { + isMember, err := IsOrganizationMember(ctx, org.ID, userID) + if err != nil { + log.Error("IsOrganizationMember: %v", err) + return false + } + return isMember +} + +// AvatarLink returns the full avatar link with http host +func (org *Organization) AvatarLink(ctx context.Context) string { + return org.AsUser().AvatarLink(ctx) +} + +// HTMLURL returns the organization's full link. +func (org *Organization) HTMLURL() string { + return org.AsUser().HTMLURL() +} + +// OrganisationLink returns the organization sub page link. +func (org *Organization) OrganisationLink() string { + return org.AsUser().OrganisationLink() +} + +// ShortName ellipses username to length +func (org *Organization) ShortName(length int) string { + return org.AsUser().ShortName(length) +} + +// HomeLink returns the user or organization home page link. +func (org *Organization) HomeLink() string { + return org.AsUser().HomeLink() +} + +// CanCreateRepo returns if user login can create a repository +// NOTE: functions calling this assume a failure due to repository count limit; if new checks are added, those functions should be revised +func (org *Organization) CanCreateRepo() bool { + return org.AsUser().CanCreateRepo() +} + +// FindOrgMembersOpts represensts find org members conditions +type FindOrgMembersOpts struct { + db.ListOptions + OrgID int64 + PublicOnly bool +} + +// CountOrgMembers counts the organization's members +func CountOrgMembers(ctx context.Context, opts *FindOrgMembersOpts) (int64, error) { + sess := db.GetEngine(ctx).Where("org_id=?", opts.OrgID) + if opts.PublicOnly { + sess.And("is_public = ?", true) + } + return sess.Count(new(OrgUser)) +} + +// FindOrgMembers loads organization members according conditions +func FindOrgMembers(ctx context.Context, opts *FindOrgMembersOpts) (user_model.UserList, map[int64]bool, error) { + ous, err := GetOrgUsersByOrgID(ctx, opts) + if err != nil { + return nil, nil, err + } + + ids := make([]int64, len(ous)) + idsIsPublic := make(map[int64]bool, len(ous)) + for i, ou := range ous { + ids[i] = ou.UID + idsIsPublic[ou.UID] = ou.IsPublic + } + + users, err := user_model.GetUsersByIDs(ctx, ids) + if err != nil { + return nil, nil, err + } + return users, idsIsPublic, nil +} + +// AsUser returns the org as user object +func (org *Organization) AsUser() *user_model.User { + return (*user_model.User)(org) +} + +// DisplayName returns full name if it's not empty, +// returns username otherwise. +func (org *Organization) DisplayName() string { + return org.AsUser().DisplayName() +} + +// CustomAvatarRelativePath returns user custom avatar relative path. +func (org *Organization) CustomAvatarRelativePath() string { + return org.Avatar +} + +// UnitPermission returns unit permission +func (org *Organization) UnitPermission(ctx context.Context, doer *user_model.User, unitType unit.Type) perm.AccessMode { + if doer != nil { + teams, err := GetUserOrgTeams(ctx, org.ID, doer.ID) + if err != nil { + log.Error("GetUserOrgTeams: %v", err) + return perm.AccessModeNone + } + + if err := teams.LoadUnits(ctx); err != nil { + log.Error("LoadUnits: %v", err) + return perm.AccessModeNone + } + + if len(teams) > 0 { + return teams.UnitMaxAccess(unitType) + } + } + + if org.Visibility.IsPublic() || (org.Visibility.IsLimited() && doer != nil) { + return perm.AccessModeRead + } + + return perm.AccessModeNone +} + +// CreateOrganization creates record of a new organization. +func CreateOrganization(ctx context.Context, org *Organization, owner *user_model.User) (err error) { + if !owner.CanCreateOrganization() { + return ErrUserNotAllowedCreateOrg{} + } + + if err = user_model.IsUsableUsername(org.Name); err != nil { + return err + } + + isExist, err := user_model.IsUserExist(ctx, 0, org.Name) + if err != nil { + return err + } else if isExist { + return user_model.ErrUserAlreadyExist{Name: org.Name} + } + + org.LowerName = strings.ToLower(org.Name) + if org.Rands, err = user_model.GetUserSalt(); err != nil { + return err + } + if org.Salt, err = user_model.GetUserSalt(); err != nil { + return err + } + org.UseCustomAvatar = true + org.MaxRepoCreation = -1 + org.NumTeams = 1 + org.NumMembers = 1 + org.Type = user_model.UserTypeOrganization + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if err = user_model.DeleteUserRedirect(ctx, org.Name); err != nil { + return err + } + + if err = db.Insert(ctx, org); err != nil { + return fmt.Errorf("insert organization: %w", err) + } + if err = user_model.GenerateRandomAvatar(ctx, org.AsUser()); err != nil { + return fmt.Errorf("generate random avatar: %w", err) + } + + // Add initial creator to organization and owner team. + if err = db.Insert(ctx, &OrgUser{ + UID: owner.ID, + OrgID: org.ID, + IsPublic: setting.Service.DefaultOrgMemberVisible, + }); err != nil { + return fmt.Errorf("insert org-user relation: %w", err) + } + + // Create default owner team. + t := &Team{ + OrgID: org.ID, + LowerName: strings.ToLower(OwnerTeamName), + Name: OwnerTeamName, + AccessMode: perm.AccessModeOwner, + NumMembers: 1, + IncludesAllRepositories: true, + CanCreateOrgRepo: true, + } + if err = db.Insert(ctx, t); err != nil { + return fmt.Errorf("insert owner team: %w", err) + } + + // insert units for team + units := make([]TeamUnit, 0, len(unit.AllRepoUnitTypes)) + for _, tp := range unit.AllRepoUnitTypes { + up := perm.AccessModeOwner + if tp == unit.TypeExternalTracker || tp == unit.TypeExternalWiki { + up = perm.AccessModeRead + } + units = append(units, TeamUnit{ + OrgID: org.ID, + TeamID: t.ID, + Type: tp, + AccessMode: up, + }) + } + + if err = db.Insert(ctx, &units); err != nil { + return err + } + + if err = db.Insert(ctx, &TeamUser{ + UID: owner.ID, + OrgID: org.ID, + TeamID: t.ID, + }); err != nil { + return fmt.Errorf("insert team-user relation: %w", err) + } + + return committer.Commit() +} + +// GetOrgByName returns organization by given name. +func GetOrgByName(ctx context.Context, name string) (*Organization, error) { + if len(name) == 0 { + return nil, ErrOrgNotExist{0, name} + } + u := &Organization{ + LowerName: strings.ToLower(name), + Type: user_model.UserTypeOrganization, + } + has, err := db.GetEngine(ctx).Get(u) + if err != nil { + return nil, err + } else if !has { + return nil, ErrOrgNotExist{0, name} + } + return u, nil +} + +// DeleteOrganization deletes models associated to an organization. +func DeleteOrganization(ctx context.Context, org *Organization) error { + if org.Type != user_model.UserTypeOrganization { + return fmt.Errorf("%s is a user not an organization", org.Name) + } + + if err := db.DeleteBeans(ctx, + &Team{OrgID: org.ID}, + &OrgUser{OrgID: org.ID}, + &TeamUser{OrgID: org.ID}, + &TeamUnit{OrgID: org.ID}, + &TeamInvite{OrgID: org.ID}, + &secret_model.Secret{OwnerID: org.ID}, + &actions_model.ActionRunner{OwnerID: org.ID}, + &actions_model.ActionRunnerToken{OwnerID: org.ID}, + ); err != nil { + return fmt.Errorf("DeleteBeans: %w", err) + } + + if _, err := db.GetEngine(ctx).ID(org.ID).Delete(new(user_model.User)); err != nil { + return fmt.Errorf("Delete: %w", err) + } + + return nil +} + +// GetOrgUserMaxAuthorizeLevel returns highest authorize level of user in an organization +func (org *Organization) GetOrgUserMaxAuthorizeLevel(ctx context.Context, uid int64) (perm.AccessMode, error) { + var authorize perm.AccessMode + _, err := db.GetEngine(ctx). + Select("max(team.authorize)"). + Table("team"). + Join("INNER", "team_user", "team_user.team_id = team.id"). + Where("team_user.uid = ?", uid). + And("team_user.org_id = ?", org.ID). + Get(&authorize) + return authorize, err +} + +// GetUsersWhoCanCreateOrgRepo returns users which are able to create repo in organization +func GetUsersWhoCanCreateOrgRepo(ctx context.Context, orgID int64) (map[int64]*user_model.User, error) { + // Use a map, in order to de-duplicate users. + users := make(map[int64]*user_model.User) + return users, db.GetEngine(ctx). + Join("INNER", "`team_user`", "`team_user`.uid=`user`.id"). + Join("INNER", "`team`", "`team`.id=`team_user`.team_id"). + Where(builder.Eq{"team.can_create_org_repo": true}.Or(builder.Eq{"team.authorize": perm.AccessModeOwner})). + And("team_user.org_id = ?", orgID).Find(&users) +} + +// SearchOrganizationsOptions options to filter organizations +type SearchOrganizationsOptions struct { + db.ListOptions + All bool +} + +// FindOrgOptions finds orgs options +type FindOrgOptions struct { + db.ListOptions + UserID int64 + IncludePrivate bool +} + +func queryUserOrgIDs(userID int64, includePrivate bool) *builder.Builder { + cond := builder.Eq{"uid": userID} + if !includePrivate { + cond["is_public"] = true + } + return builder.Select("org_id").From("org_user").Where(cond) +} + +func (opts FindOrgOptions) ToConds() builder.Cond { + var cond builder.Cond = builder.Eq{"`user`.`type`": user_model.UserTypeOrganization} + if opts.UserID > 0 { + cond = cond.And(builder.In("`user`.`id`", queryUserOrgIDs(opts.UserID, opts.IncludePrivate))) + } + if !opts.IncludePrivate { + cond = cond.And(builder.Eq{"`user`.visibility": structs.VisibleTypePublic}) + } + return cond +} + +func (opts FindOrgOptions) ToOrders() string { + return "`user`.name ASC" +} + +// HasOrgOrUserVisible tells if the given user can see the given org or user +func HasOrgOrUserVisible(ctx context.Context, orgOrUser, user *user_model.User) bool { + // If user is nil, it's an anonymous user/request. + // The Ghost user is handled like an anonymous user. + if user == nil || user.IsGhost() { + return orgOrUser.Visibility == structs.VisibleTypePublic + } + + if user.IsAdmin || orgOrUser.ID == user.ID { + return true + } + + if (orgOrUser.Visibility == structs.VisibleTypePrivate || user.IsRestricted) && !OrgFromUser(orgOrUser).hasMemberWithUserID(ctx, user.ID) { + return false + } + return true +} + +// HasOrgsVisible tells if the given user can see at least one of the orgs provided +func HasOrgsVisible(ctx context.Context, orgs []*Organization, user *user_model.User) bool { + if len(orgs) == 0 { + return false + } + + for _, org := range orgs { + if HasOrgOrUserVisible(ctx, org.AsUser(), user) { + return true + } + } + return false +} + +// GetOrgsCanCreateRepoByUserID returns a list of organizations where given user ID +// are allowed to create repos. +func GetOrgsCanCreateRepoByUserID(ctx context.Context, userID int64) ([]*Organization, error) { + orgs := make([]*Organization, 0, 10) + + return orgs, db.GetEngine(ctx).Where(builder.In("id", builder.Select("`user`.id").From("`user`"). + Join("INNER", "`team_user`", "`team_user`.org_id = `user`.id"). + Join("INNER", "`team`", "`team`.id = `team_user`.team_id"). + Where(builder.Eq{"`team_user`.uid": userID}). + And(builder.Eq{"`team`.authorize": perm.AccessModeOwner}.Or(builder.Eq{"`team`.can_create_org_repo": true})))). + Asc("`user`.name"). + Find(&orgs) +} + +// GetOrgUsersByOrgID returns all organization-user relations by organization ID. +func GetOrgUsersByOrgID(ctx context.Context, opts *FindOrgMembersOpts) ([]*OrgUser, error) { + sess := db.GetEngine(ctx).Where("org_id=?", opts.OrgID) + if opts.PublicOnly { + sess.And("is_public = ?", true) + } + if opts.ListOptions.PageSize > 0 { + sess = db.SetSessionPagination(sess, opts) + + ous := make([]*OrgUser, 0, opts.PageSize) + return ous, sess.Find(&ous) + } + + var ous []*OrgUser + return ous, sess.Find(&ous) +} + +// ChangeOrgUserStatus changes public or private membership status. +func ChangeOrgUserStatus(ctx context.Context, orgID, uid int64, public bool) error { + ou := new(OrgUser) + has, err := db.GetEngine(ctx). + Where("uid=?", uid). + And("org_id=?", orgID). + Get(ou) + if err != nil { + return err + } else if !has { + return nil + } + + ou.IsPublic = public + _, err = db.GetEngine(ctx).ID(ou.ID).Cols("is_public").Update(ou) + return err +} + +// AddOrgUser adds new user to given organization. +func AddOrgUser(ctx context.Context, orgID, uid int64) error { + isAlreadyMember, err := IsOrganizationMember(ctx, orgID, uid) + if err != nil || isAlreadyMember { + return err + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + // check in transaction + isAlreadyMember, err = IsOrganizationMember(ctx, orgID, uid) + if err != nil || isAlreadyMember { + return err + } + + ou := &OrgUser{ + UID: uid, + OrgID: orgID, + IsPublic: setting.Service.DefaultOrgMemberVisible, + } + + if err := db.Insert(ctx, ou); err != nil { + return err + } else if _, err = db.Exec(ctx, "UPDATE `user` SET num_members = num_members + 1 WHERE id = ?", orgID); err != nil { + return err + } + + return committer.Commit() +} + +// GetOrgByID returns the user object by given ID if exists. +func GetOrgByID(ctx context.Context, id int64) (*Organization, error) { + u := new(Organization) + has, err := db.GetEngine(ctx).ID(id).Get(u) + if err != nil { + return nil, err + } else if !has { + return nil, user_model.ErrUserNotExist{ + UID: id, + } + } + return u, nil +} + +// RemoveOrgRepo removes all team-repository relations of organization. +func RemoveOrgRepo(ctx context.Context, orgID, repoID int64) error { + teamRepos := make([]*TeamRepo, 0, 10) + e := db.GetEngine(ctx) + if err := e.Find(&teamRepos, &TeamRepo{OrgID: orgID, RepoID: repoID}); err != nil { + return err + } + + if len(teamRepos) == 0 { + return nil + } + + if _, err := e.Delete(&TeamRepo{ + OrgID: orgID, + RepoID: repoID, + }); err != nil { + return err + } + + teamIDs := make([]int64, len(teamRepos)) + for i, teamRepo := range teamRepos { + teamIDs[i] = teamRepo.TeamID + } + + _, err := e.Decr("num_repos").In("id", teamIDs).Update(new(Team)) + return err +} + +func (org *Organization) getUserTeams(ctx context.Context, userID int64, cols ...string) ([]*Team, error) { + teams := make([]*Team, 0, org.NumTeams) + return teams, db.GetEngine(ctx). + Where("`team_user`.org_id = ?", org.ID). + Join("INNER", "team_user", "`team_user`.team_id = team.id"). + Join("INNER", "`user`", "`user`.id=team_user.uid"). + And("`team_user`.uid = ?", userID). + Asc("`user`.name"). + Cols(cols...). + Find(&teams) +} + +func (org *Organization) getUserTeamIDs(ctx context.Context, userID int64) ([]int64, error) { + teamIDs := make([]int64, 0, org.NumTeams) + return teamIDs, db.GetEngine(ctx). + Table("team"). + Cols("team.id"). + Where("`team_user`.org_id = ?", org.ID). + Join("INNER", "team_user", "`team_user`.team_id = team.id"). + And("`team_user`.uid = ?", userID). + Find(&teamIDs) +} + +// TeamsWithAccessToRepo returns all teams that have given access level to the repository. +func (org *Organization) TeamsWithAccessToRepo(ctx context.Context, repoID int64, mode perm.AccessMode) ([]*Team, error) { + return GetTeamsWithAccessToRepo(ctx, org.ID, repoID, mode) +} + +// GetUserTeamIDs returns of all team IDs of the organization that user is member of. +func (org *Organization) GetUserTeamIDs(ctx context.Context, userID int64) ([]int64, error) { + return org.getUserTeamIDs(ctx, userID) +} + +// GetUserTeams returns all teams that belong to user, +// and that the user has joined. +func (org *Organization) GetUserTeams(ctx context.Context, userID int64) ([]*Team, error) { + return org.getUserTeams(ctx, userID) +} + +// AccessibleReposEnvironment operations involving the repositories that are +// accessible to a particular user +type AccessibleReposEnvironment interface { + CountRepos() (int64, error) + RepoIDs(page, pageSize int) ([]int64, error) + Repos(page, pageSize int) (repo_model.RepositoryList, error) + MirrorRepos() (repo_model.RepositoryList, error) + AddKeyword(keyword string) + SetSort(db.SearchOrderBy) +} + +type accessibleReposEnv struct { + org *Organization + user *user_model.User + team *Team + teamIDs []int64 + ctx context.Context + keyword string + orderBy db.SearchOrderBy +} + +// AccessibleReposEnv builds an AccessibleReposEnvironment for the repositories in `org` +// that are accessible to the specified user. +func AccessibleReposEnv(ctx context.Context, org *Organization, userID int64) (AccessibleReposEnvironment, error) { + var user *user_model.User + + if userID > 0 { + u, err := user_model.GetUserByID(ctx, userID) + if err != nil { + return nil, err + } + user = u + } + + teamIDs, err := org.getUserTeamIDs(ctx, userID) + if err != nil { + return nil, err + } + return &accessibleReposEnv{ + org: org, + user: user, + teamIDs: teamIDs, + ctx: ctx, + orderBy: db.SearchOrderByRecentUpdated, + }, nil +} + +// AccessibleTeamReposEnv an AccessibleReposEnvironment for the repositories in `org` +// that are accessible to the specified team. +func (org *Organization) AccessibleTeamReposEnv(ctx context.Context, team *Team) AccessibleReposEnvironment { + return &accessibleReposEnv{ + org: org, + team: team, + ctx: ctx, + orderBy: db.SearchOrderByRecentUpdated, + } +} + +func (env *accessibleReposEnv) cond() builder.Cond { + cond := builder.NewCond() + if env.team != nil { + cond = cond.And(builder.Eq{"team_repo.team_id": env.team.ID}) + } else { + if env.user == nil || !env.user.IsRestricted { + cond = cond.Or(builder.Eq{ + "`repository`.owner_id": env.org.ID, + "`repository`.is_private": false, + }) + } + if len(env.teamIDs) > 0 { + cond = cond.Or(builder.In("team_repo.team_id", env.teamIDs)) + } + } + if env.keyword != "" { + cond = cond.And(builder.Like{"`repository`.lower_name", strings.ToLower(env.keyword)}) + } + return cond +} + +func (env *accessibleReposEnv) CountRepos() (int64, error) { + repoCount, err := db.GetEngine(env.ctx). + Join("INNER", "team_repo", "`team_repo`.repo_id=`repository`.id"). + Where(env.cond()). + Distinct("`repository`.id"). + Count(&repo_model.Repository{}) + if err != nil { + return 0, fmt.Errorf("count user repositories in organization: %w", err) + } + return repoCount, nil +} + +func (env *accessibleReposEnv) RepoIDs(page, pageSize int) ([]int64, error) { + if page <= 0 { + page = 1 + } + + repoIDs := make([]int64, 0, pageSize) + return repoIDs, db.GetEngine(env.ctx). + Table("repository"). + Join("INNER", "team_repo", "`team_repo`.repo_id=`repository`.id"). + Where(env.cond()). + GroupBy("`repository`.id,`repository`."+strings.Fields(string(env.orderBy))[0]). + OrderBy(string(env.orderBy)). + Limit(pageSize, (page-1)*pageSize). + Cols("`repository`.id"). + Find(&repoIDs) +} + +func (env *accessibleReposEnv) Repos(page, pageSize int) (repo_model.RepositoryList, error) { + repoIDs, err := env.RepoIDs(page, pageSize) + if err != nil { + return nil, fmt.Errorf("GetUserRepositoryIDs: %w", err) + } + + repos := make([]*repo_model.Repository, 0, len(repoIDs)) + if len(repoIDs) == 0 { + return repos, nil + } + + return repos, db.GetEngine(env.ctx). + In("`repository`.id", repoIDs). + OrderBy(string(env.orderBy)). + Find(&repos) +} + +func (env *accessibleReposEnv) MirrorRepoIDs() ([]int64, error) { + repoIDs := make([]int64, 0, 10) + return repoIDs, db.GetEngine(env.ctx). + Table("repository"). + Join("INNER", "team_repo", "`team_repo`.repo_id=`repository`.id AND `repository`.is_mirror=?", true). + Where(env.cond()). + GroupBy("`repository`.id, `repository`.updated_unix"). + OrderBy(string(env.orderBy)). + Cols("`repository`.id"). + Find(&repoIDs) +} + +func (env *accessibleReposEnv) MirrorRepos() (repo_model.RepositoryList, error) { + repoIDs, err := env.MirrorRepoIDs() + if err != nil { + return nil, fmt.Errorf("MirrorRepoIDs: %w", err) + } + + repos := make([]*repo_model.Repository, 0, len(repoIDs)) + if len(repoIDs) == 0 { + return repos, nil + } + + return repos, db.GetEngine(env.ctx). + In("`repository`.id", repoIDs). + Find(&repos) +} + +func (env *accessibleReposEnv) AddKeyword(keyword string) { + env.keyword = keyword +} + +func (env *accessibleReposEnv) SetSort(orderBy db.SearchOrderBy) { + env.orderBy = orderBy +} diff --git a/models/organization/org_repo.go b/models/organization/org_repo.go new file mode 100644 index 0000000..f7e5992 --- /dev/null +++ b/models/organization/org_repo.go @@ -0,0 +1,17 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "context" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" +) + +// GetOrgRepositories get repos belonging to the given organization +func GetOrgRepositories(ctx context.Context, orgID int64) (repo_model.RepositoryList, error) { + var orgRepos []*repo_model.Repository + return orgRepos, db.GetEngine(ctx).Where("owner_id = ?", orgID).Find(&orgRepos) +} diff --git a/models/organization/org_test.go b/models/organization/org_test.go new file mode 100644 index 0000000..21d954b --- /dev/null +++ b/models/organization/org_test.go @@ -0,0 +1,548 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "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" + "github.com/stretchr/testify/require" +) + +func TestUser_IsOwnedBy(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + for _, testCase := range []struct { + OrgID int64 + UserID int64 + ExpectedOwner bool + }{ + {3, 2, true}, + {3, 1, false}, + {3, 3, false}, + {3, 4, false}, + {2, 2, false}, // user2 is not an organization + {2, 3, false}, + } { + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: testCase.OrgID}) + isOwner, err := org.IsOwnedBy(db.DefaultContext, testCase.UserID) + require.NoError(t, err) + assert.Equal(t, testCase.ExpectedOwner, isOwner) + } +} + +func TestUser_IsOrgMember(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + for _, testCase := range []struct { + OrgID int64 + UserID int64 + ExpectedMember bool + }{ + {3, 2, true}, + {3, 4, true}, + {3, 1, false}, + {3, 3, false}, + {2, 2, false}, // user2 is not an organization + {2, 3, false}, + } { + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: testCase.OrgID}) + isMember, err := org.IsOrgMember(db.DefaultContext, testCase.UserID) + require.NoError(t, err) + assert.Equal(t, testCase.ExpectedMember, isMember) + } +} + +func TestUser_GetTeam(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + team, err := org.GetTeam(db.DefaultContext, "team1") + require.NoError(t, err) + assert.Equal(t, org.ID, team.OrgID) + assert.Equal(t, "team1", team.LowerName) + + _, err = org.GetTeam(db.DefaultContext, "does not exist") + assert.True(t, organization.IsErrTeamNotExist(err)) + + nonOrg := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 2}) + _, err = nonOrg.GetTeam(db.DefaultContext, "team") + assert.True(t, organization.IsErrTeamNotExist(err)) +} + +func TestUser_GetOwnerTeam(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + team, err := org.GetOwnerTeam(db.DefaultContext) + require.NoError(t, err) + assert.Equal(t, org.ID, team.OrgID) + + nonOrg := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 2}) + _, err = nonOrg.GetOwnerTeam(db.DefaultContext) + assert.True(t, organization.IsErrTeamNotExist(err)) +} + +func TestUser_GetTeams(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + teams, err := org.LoadTeams(db.DefaultContext) + require.NoError(t, err) + if assert.Len(t, teams, 5) { + assert.Equal(t, int64(1), teams[0].ID) + assert.Equal(t, int64(2), teams[1].ID) + assert.Equal(t, int64(12), teams[2].ID) + assert.Equal(t, int64(14), teams[3].ID) + assert.Equal(t, int64(7), teams[4].ID) + } +} + +func TestUser_GetMembers(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + members, _, err := org.GetMembers(db.DefaultContext) + require.NoError(t, err) + if assert.Len(t, members, 3) { + assert.Equal(t, int64(2), members[0].ID) + assert.Equal(t, int64(28), members[1].ID) + assert.Equal(t, int64(4), members[2].ID) + } +} + +func TestGetOrgByName(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + org, err := organization.GetOrgByName(db.DefaultContext, "org3") + require.NoError(t, err) + assert.EqualValues(t, 3, org.ID) + assert.Equal(t, "org3", org.Name) + + _, err = organization.GetOrgByName(db.DefaultContext, "user2") // user2 is an individual + assert.True(t, organization.IsErrOrgNotExist(err)) + + _, err = organization.GetOrgByName(db.DefaultContext, "") // corner case + assert.True(t, organization.IsErrOrgNotExist(err)) +} + +func TestCountOrganizations(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + expected, err := db.GetEngine(db.DefaultContext).Where("type=?", user_model.UserTypeOrganization).Count(&organization.Organization{}) + require.NoError(t, err) + cnt, err := db.Count[organization.Organization](db.DefaultContext, organization.FindOrgOptions{IncludePrivate: true}) + require.NoError(t, err) + assert.Equal(t, expected, cnt) +} + +func TestIsOrganizationOwner(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + test := func(orgID, userID int64, expected bool) { + isOwner, err := organization.IsOrganizationOwner(db.DefaultContext, orgID, userID) + require.NoError(t, err) + assert.EqualValues(t, expected, isOwner) + } + test(3, 2, true) + test(3, 3, false) + test(6, 5, true) + test(6, 4, false) + test(unittest.NonexistentID, unittest.NonexistentID, false) +} + +func TestIsOrganizationMember(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + test := func(orgID, userID int64, expected bool) { + isMember, err := organization.IsOrganizationMember(db.DefaultContext, orgID, userID) + require.NoError(t, err) + assert.EqualValues(t, expected, isMember) + } + test(3, 2, true) + test(3, 3, false) + test(3, 4, true) + test(6, 5, true) + test(6, 4, false) + test(unittest.NonexistentID, unittest.NonexistentID, false) +} + +func TestIsPublicMembership(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + test := func(orgID, userID int64, expected bool) { + isMember, err := organization.IsPublicMembership(db.DefaultContext, orgID, userID) + require.NoError(t, err) + assert.EqualValues(t, expected, isMember) + } + test(3, 2, true) + test(3, 3, false) + test(3, 4, false) + test(6, 5, true) + test(6, 4, false) + test(unittest.NonexistentID, unittest.NonexistentID, false) +} + +func TestFindOrgs(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + orgs, err := db.Find[organization.Organization](db.DefaultContext, organization.FindOrgOptions{ + UserID: 4, + IncludePrivate: true, + }) + require.NoError(t, err) + if assert.Len(t, orgs, 1) { + assert.EqualValues(t, 3, orgs[0].ID) + } + + orgs, err = db.Find[organization.Organization](db.DefaultContext, organization.FindOrgOptions{ + UserID: 4, + IncludePrivate: false, + }) + require.NoError(t, err) + assert.Empty(t, orgs) + + total, err := db.Count[organization.Organization](db.DefaultContext, organization.FindOrgOptions{ + UserID: 4, + IncludePrivate: true, + }) + require.NoError(t, err) + assert.EqualValues(t, 1, total) +} + +func TestGetOrgUsersByOrgID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + orgUsers, err := organization.GetOrgUsersByOrgID(db.DefaultContext, &organization.FindOrgMembersOpts{ + ListOptions: db.ListOptions{}, + OrgID: 3, + PublicOnly: false, + }) + require.NoError(t, err) + if assert.Len(t, orgUsers, 3) { + assert.Equal(t, organization.OrgUser{ + ID: orgUsers[0].ID, + OrgID: 3, + UID: 2, + IsPublic: true, + }, *orgUsers[0]) + assert.Equal(t, organization.OrgUser{ + ID: orgUsers[1].ID, + OrgID: 3, + UID: 4, + IsPublic: false, + }, *orgUsers[1]) + assert.Equal(t, organization.OrgUser{ + ID: orgUsers[2].ID, + OrgID: 3, + UID: 28, + IsPublic: true, + }, *orgUsers[2]) + } + + orgUsers, err = organization.GetOrgUsersByOrgID(db.DefaultContext, &organization.FindOrgMembersOpts{ + ListOptions: db.ListOptions{}, + OrgID: unittest.NonexistentID, + PublicOnly: false, + }) + require.NoError(t, err) + assert.Empty(t, orgUsers) +} + +func TestChangeOrgUserStatus(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + testSuccess := func(orgID, userID int64, public bool) { + require.NoError(t, organization.ChangeOrgUserStatus(db.DefaultContext, orgID, userID, public)) + orgUser := unittest.AssertExistsAndLoadBean(t, &organization.OrgUser{OrgID: orgID, UID: userID}) + assert.Equal(t, public, orgUser.IsPublic) + } + + testSuccess(3, 2, false) + testSuccess(3, 2, false) + testSuccess(3, 4, true) + require.NoError(t, organization.ChangeOrgUserStatus(db.DefaultContext, unittest.NonexistentID, unittest.NonexistentID, true)) +} + +func TestUser_GetUserTeamIDs(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + testSuccess := func(userID int64, expected []int64) { + teamIDs, err := org.GetUserTeamIDs(db.DefaultContext, userID) + require.NoError(t, err) + assert.Equal(t, expected, teamIDs) + } + testSuccess(2, []int64{1, 2, 14}) + testSuccess(4, []int64{2}) + testSuccess(unittest.NonexistentID, []int64{}) +} + +func TestAccessibleReposEnv_CountRepos(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + testSuccess := func(userID, expectedCount int64) { + env, err := organization.AccessibleReposEnv(db.DefaultContext, org, userID) + require.NoError(t, err) + count, err := env.CountRepos() + require.NoError(t, err) + assert.EqualValues(t, expectedCount, count) + } + testSuccess(2, 3) + testSuccess(4, 2) +} + +func TestAccessibleReposEnv_RepoIDs(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + testSuccess := func(userID int64, expectedRepoIDs []int64) { + env, err := organization.AccessibleReposEnv(db.DefaultContext, org, userID) + require.NoError(t, err) + repoIDs, err := env.RepoIDs(1, 100) + require.NoError(t, err) + assert.Equal(t, expectedRepoIDs, repoIDs) + } + testSuccess(2, []int64{32, 5, 3}) + testSuccess(4, []int64{32, 3}) +} + +func TestAccessibleReposEnv_Repos(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + testSuccess := func(userID int64, expectedRepoIDs []int64) { + env, err := organization.AccessibleReposEnv(db.DefaultContext, org, userID) + require.NoError(t, err) + repos, err := env.Repos(1, 100) + require.NoError(t, err) + expectedRepos := make(repo_model.RepositoryList, len(expectedRepoIDs)) + for i, repoID := range expectedRepoIDs { + expectedRepos[i] = unittest.AssertExistsAndLoadBean(t, + &repo_model.Repository{ID: repoID}) + } + assert.Equal(t, expectedRepos, repos) + } + testSuccess(2, []int64{32, 5, 3}) + testSuccess(4, []int64{32, 3}) +} + +func TestAccessibleReposEnv_MirrorRepos(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + testSuccess := func(userID int64, expectedRepoIDs []int64) { + env, err := organization.AccessibleReposEnv(db.DefaultContext, org, userID) + require.NoError(t, err) + repos, err := env.MirrorRepos() + require.NoError(t, err) + expectedRepos := make(repo_model.RepositoryList, len(expectedRepoIDs)) + for i, repoID := range expectedRepoIDs { + expectedRepos[i] = unittest.AssertExistsAndLoadBean(t, + &repo_model.Repository{ID: repoID}) + } + assert.Equal(t, expectedRepos, repos) + } + testSuccess(2, []int64{5}) + testSuccess(4, []int64{}) +} + +func TestHasOrgVisibleTypePublic(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + + const newOrgName = "test-org-public" + org := &organization.Organization{ + Name: newOrgName, + Visibility: structs.VisibleTypePublic, + } + + unittest.AssertNotExistsBean(t, &user_model.User{Name: org.Name, Type: user_model.UserTypeOrganization}) + require.NoError(t, organization.CreateOrganization(db.DefaultContext, org, owner)) + org = unittest.AssertExistsAndLoadBean(t, + &organization.Organization{Name: org.Name, Type: user_model.UserTypeOrganization}) + test1 := organization.HasOrgOrUserVisible(db.DefaultContext, org.AsUser(), owner) + test2 := organization.HasOrgOrUserVisible(db.DefaultContext, org.AsUser(), org3) + test3 := organization.HasOrgOrUserVisible(db.DefaultContext, org.AsUser(), nil) + assert.True(t, test1) // owner of org + assert.True(t, test2) // user not a part of org + assert.True(t, test3) // logged out user +} + +func TestHasOrgVisibleTypeLimited(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + + const newOrgName = "test-org-limited" + org := &organization.Organization{ + Name: newOrgName, + Visibility: structs.VisibleTypeLimited, + } + + unittest.AssertNotExistsBean(t, &user_model.User{Name: org.Name, Type: user_model.UserTypeOrganization}) + require.NoError(t, organization.CreateOrganization(db.DefaultContext, org, owner)) + org = unittest.AssertExistsAndLoadBean(t, + &organization.Organization{Name: org.Name, Type: user_model.UserTypeOrganization}) + test1 := organization.HasOrgOrUserVisible(db.DefaultContext, org.AsUser(), owner) + test2 := organization.HasOrgOrUserVisible(db.DefaultContext, org.AsUser(), org3) + test3 := organization.HasOrgOrUserVisible(db.DefaultContext, org.AsUser(), nil) + assert.True(t, test1) // owner of org + assert.True(t, test2) // user not a part of org + assert.False(t, test3) // logged out user +} + +func TestHasOrgVisibleTypePrivate(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + + const newOrgName = "test-org-private" + org := &organization.Organization{ + Name: newOrgName, + Visibility: structs.VisibleTypePrivate, + } + + unittest.AssertNotExistsBean(t, &user_model.User{Name: org.Name, Type: user_model.UserTypeOrganization}) + require.NoError(t, organization.CreateOrganization(db.DefaultContext, org, owner)) + org = unittest.AssertExistsAndLoadBean(t, + &organization.Organization{Name: org.Name, Type: user_model.UserTypeOrganization}) + test1 := organization.HasOrgOrUserVisible(db.DefaultContext, org.AsUser(), owner) + test2 := organization.HasOrgOrUserVisible(db.DefaultContext, org.AsUser(), org3) + test3 := organization.HasOrgOrUserVisible(db.DefaultContext, org.AsUser(), nil) + assert.True(t, test1) // owner of org + assert.False(t, test2) // user not a part of org + assert.False(t, test3) // logged out user +} + +func TestGetUsersWhoCanCreateOrgRepo(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + users, err := organization.GetUsersWhoCanCreateOrgRepo(db.DefaultContext, 3) + require.NoError(t, err) + assert.Len(t, users, 2) + var ids []int64 + for i := range users { + ids = append(ids, users[i].ID) + } + assert.ElementsMatch(t, ids, []int64{2, 28}) + + users, err = organization.GetUsersWhoCanCreateOrgRepo(db.DefaultContext, 7) + require.NoError(t, err) + assert.Len(t, users, 1) + assert.NotNil(t, users[5]) +} + +func TestUser_RemoveOrgRepo(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: org.ID}) + + // remove a repo that does belong to org + unittest.AssertExistsAndLoadBean(t, &organization.TeamRepo{RepoID: repo.ID, OrgID: org.ID}) + require.NoError(t, organization.RemoveOrgRepo(db.DefaultContext, org.ID, repo.ID)) + unittest.AssertNotExistsBean(t, &organization.TeamRepo{RepoID: repo.ID, OrgID: org.ID}) + unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID}) // repo should still exist + + // remove a repo that does not belong to org + require.NoError(t, organization.RemoveOrgRepo(db.DefaultContext, org.ID, repo.ID)) + unittest.AssertNotExistsBean(t, &organization.TeamRepo{RepoID: repo.ID, OrgID: org.ID}) + + require.NoError(t, organization.RemoveOrgRepo(db.DefaultContext, org.ID, unittest.NonexistentID)) + + unittest.CheckConsistencyFor(t, + &user_model.User{ID: org.ID}, + &organization.Team{OrgID: org.ID}, + &repo_model.Repository{ID: repo.ID}) +} + +func TestCreateOrganization(t *testing.T) { + // successful creation of org + require.NoError(t, unittest.PrepareTestDatabase()) + + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + const newOrgName = "neworg" + org := &organization.Organization{ + Name: newOrgName, + } + + unittest.AssertNotExistsBean(t, &user_model.User{Name: newOrgName, Type: user_model.UserTypeOrganization}) + require.NoError(t, organization.CreateOrganization(db.DefaultContext, org, owner)) + org = unittest.AssertExistsAndLoadBean(t, + &organization.Organization{Name: newOrgName, Type: user_model.UserTypeOrganization}) + ownerTeam := unittest.AssertExistsAndLoadBean(t, + &organization.Team{Name: organization.OwnerTeamName, OrgID: org.ID}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{UID: owner.ID, TeamID: ownerTeam.ID}) + unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{}) +} + +func TestCreateOrganization2(t *testing.T) { + // unauthorized creation of org + require.NoError(t, unittest.PrepareTestDatabase()) + + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + const newOrgName = "neworg" + org := &organization.Organization{ + Name: newOrgName, + } + + unittest.AssertNotExistsBean(t, &organization.Organization{Name: newOrgName, Type: user_model.UserTypeOrganization}) + err := organization.CreateOrganization(db.DefaultContext, org, owner) + require.Error(t, err) + assert.True(t, organization.IsErrUserNotAllowedCreateOrg(err)) + unittest.AssertNotExistsBean(t, &organization.Organization{Name: newOrgName, Type: user_model.UserTypeOrganization}) + unittest.CheckConsistencyFor(t, &organization.Organization{}, &organization.Team{}) +} + +func TestCreateOrganization3(t *testing.T) { + // create org with same name as existent org + require.NoError(t, unittest.PrepareTestDatabase()) + + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + org := &organization.Organization{Name: "org3"} // should already exist + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: org.Name}) // sanity check + err := organization.CreateOrganization(db.DefaultContext, org, owner) + require.Error(t, err) + assert.True(t, user_model.IsErrUserAlreadyExist(err)) + unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{}) +} + +func TestCreateOrganization4(t *testing.T) { + // create org with unusable name + require.NoError(t, unittest.PrepareTestDatabase()) + + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + err := organization.CreateOrganization(db.DefaultContext, &organization.Organization{Name: "assets"}, owner) + require.Error(t, err) + assert.True(t, db.IsErrNameReserved(err)) + unittest.CheckConsistencyFor(t, &organization.Organization{}, &organization.Team{}) +} + +func TestUnitPermission(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + publicOrg := &organization.Organization{ID: 1001, Visibility: structs.VisibleTypePublic} + limitedOrg := &organization.Organization{ID: 1001, Visibility: structs.VisibleTypeLimited} + privateOrg := &organization.Organization{ID: 1001, Visibility: structs.VisibleTypePrivate} + user := &user_model.User{ID: 1001} + t.Run("Anonymous", func(t *testing.T) { + t.Run("Public", func(t *testing.T) { + assert.EqualValues(t, perm.AccessModeRead, publicOrg.UnitPermission(db.DefaultContext, nil, unit.TypeCode)) + }) + t.Run("Limited", func(t *testing.T) { + assert.EqualValues(t, perm.AccessModeNone, limitedOrg.UnitPermission(db.DefaultContext, nil, unit.TypeCode)) + }) + t.Run("Private", func(t *testing.T) { + assert.EqualValues(t, perm.AccessModeNone, privateOrg.UnitPermission(db.DefaultContext, nil, unit.TypeCode)) + }) + }) + + t.Run("Logged in", func(t *testing.T) { + t.Run("Public", func(t *testing.T) { + assert.EqualValues(t, perm.AccessModeRead, publicOrg.UnitPermission(db.DefaultContext, user, unit.TypeCode)) + }) + t.Run("Limited", func(t *testing.T) { + assert.EqualValues(t, perm.AccessModeRead, limitedOrg.UnitPermission(db.DefaultContext, user, unit.TypeCode)) + }) + t.Run("Private", func(t *testing.T) { + assert.EqualValues(t, perm.AccessModeNone, privateOrg.UnitPermission(db.DefaultContext, user, unit.TypeCode)) + }) + }) +} diff --git a/models/organization/org_user.go b/models/organization/org_user.go new file mode 100644 index 0000000..5fe3a17 --- /dev/null +++ b/models/organization/org_user.go @@ -0,0 +1,138 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + + "xorm.io/builder" +) + +// ________ ____ ___ +// \_____ \_______ ____ | | \______ ___________ +// / | \_ __ \/ ___\| | / ___// __ \_ __ \ +// / | \ | \/ /_/ > | /\___ \\ ___/| | \/ +// \_______ /__| \___ /|______//____ >\___ >__| +// \/ /_____/ \/ \/ + +// OrgUser represents an organization-user relation. +type OrgUser struct { + ID int64 `xorm:"pk autoincr"` + UID int64 `xorm:"INDEX UNIQUE(s)"` + OrgID int64 `xorm:"INDEX UNIQUE(s)"` + IsPublic bool `xorm:"INDEX"` +} + +func init() { + db.RegisterModel(new(OrgUser)) +} + +// GetOrganizationCount returns count of membership of organization of the user. +func GetOrganizationCount(ctx context.Context, u *user_model.User) (int64, error) { + return db.GetEngine(ctx). + Where("uid=?", u.ID). + Count(new(OrgUser)) +} + +// IsOrganizationOwner returns true if given user is in the owner team. +func IsOrganizationOwner(ctx context.Context, orgID, uid int64) (bool, error) { + ownerTeam, err := GetOwnerTeam(ctx, orgID) + if err != nil { + if IsErrTeamNotExist(err) { + log.Error("Organization does not have owner team: %d", orgID) + return false, nil + } + return false, err + } + return IsTeamMember(ctx, orgID, ownerTeam.ID, uid) +} + +// IsOrganizationAdmin returns true if given user is in the owner team or an admin team. +func IsOrganizationAdmin(ctx context.Context, orgID, uid int64) (bool, error) { + teams, err := GetUserOrgTeams(ctx, orgID, uid) + if err != nil { + return false, err + } + for _, t := range teams { + if t.AccessMode >= perm.AccessModeAdmin { + return true, nil + } + } + return false, nil +} + +// IsOrganizationMember returns true if given user is member of organization. +func IsOrganizationMember(ctx context.Context, orgID, uid int64) (bool, error) { + return db.GetEngine(ctx). + Where("uid=?", uid). + And("org_id=?", orgID). + Table("org_user"). + Exist() +} + +// IsPublicMembership returns true if the given user's membership of given org is public. +func IsPublicMembership(ctx context.Context, orgID, uid int64) (bool, error) { + return db.GetEngine(ctx). + Where("uid=?", uid). + And("org_id=?", orgID). + And("is_public=?", true). + Table("org_user"). + Exist() +} + +// CanCreateOrgRepo returns true if user can create repo in organization +func CanCreateOrgRepo(ctx context.Context, orgID, uid int64) (bool, error) { + return db.GetEngine(ctx). + Where(builder.Eq{"team.can_create_org_repo": true}). + Join("INNER", "team_user", "team_user.team_id = team.id"). + And("team_user.uid = ?", uid). + And("team_user.org_id = ?", orgID). + Exist(new(Team)) +} + +// IsUserOrgOwner returns true if user is in the owner team of given organization. +func IsUserOrgOwner(ctx context.Context, users user_model.UserList, orgID int64) map[int64]bool { + results := make(map[int64]bool, len(users)) + for _, user := range users { + results[user.ID] = false // Set default to false + } + ownerMaps, err := loadOrganizationOwners(ctx, users, orgID) + if err == nil { + for _, owner := range ownerMaps { + results[owner.UID] = true + } + } + return results +} + +func loadOrganizationOwners(ctx context.Context, users user_model.UserList, orgID int64) (map[int64]*TeamUser, error) { + if len(users) == 0 { + return nil, nil + } + ownerTeam, err := GetOwnerTeam(ctx, orgID) + if err != nil { + if IsErrTeamNotExist(err) { + log.Error("Organization does not have owner team: %d", orgID) + return nil, nil + } + return nil, err + } + + userIDs := users.GetUserIDs() + ownerMaps := make(map[int64]*TeamUser) + err = db.GetEngine(ctx).In("uid", userIDs). + And("org_id=?", orgID). + And("team_id=?", ownerTeam.ID). + Find(&ownerMaps) + if err != nil { + return nil, fmt.Errorf("find team users: %w", err) + } + return ownerMaps, nil +} diff --git a/models/organization/org_user_test.go b/models/organization/org_user_test.go new file mode 100644 index 0000000..07d07ce --- /dev/null +++ b/models/organization/org_user_test.go @@ -0,0 +1,155 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization_test + +import ( + "fmt" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserIsPublicMember(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + tt := []struct { + uid int64 + orgid int64 + expected bool + }{ + {2, 3, true}, + {4, 3, false}, + {5, 6, true}, + {5, 7, false}, + } + for _, v := range tt { + t.Run(fmt.Sprintf("UserId%dIsPublicMemberOf%d", v.uid, v.orgid), func(t *testing.T) { + testUserIsPublicMember(t, v.uid, v.orgid, v.expected) + }) + } +} + +func testUserIsPublicMember(t *testing.T, uid, orgID int64, expected bool) { + user, err := user_model.GetUserByID(db.DefaultContext, uid) + require.NoError(t, err) + is, err := organization.IsPublicMembership(db.DefaultContext, orgID, user.ID) + require.NoError(t, err) + assert.Equal(t, expected, is) +} + +func TestIsUserOrgOwner(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + tt := []struct { + uid int64 + orgid int64 + expected bool + }{ + {2, 3, true}, + {4, 3, false}, + {5, 6, true}, + {5, 7, true}, + } + for _, v := range tt { + t.Run(fmt.Sprintf("UserId%dIsOrgOwnerOf%d", v.uid, v.orgid), func(t *testing.T) { + testIsUserOrgOwner(t, v.uid, v.orgid, v.expected) + }) + } +} + +func testIsUserOrgOwner(t *testing.T, uid, orgID int64, expected bool) { + user, err := user_model.GetUserByID(db.DefaultContext, uid) + require.NoError(t, err) + is, err := organization.IsOrganizationOwner(db.DefaultContext, orgID, user.ID) + require.NoError(t, err) + assert.Equal(t, expected, is) +} + +func TestUserListIsPublicMember(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + tt := []struct { + orgid int64 + expected map[int64]bool + }{ + {3, map[int64]bool{2: true, 4: false, 28: true}}, + {6, map[int64]bool{5: true, 28: true}}, + {7, map[int64]bool{5: false}}, + {25, map[int64]bool{24: true}}, + {22, map[int64]bool{}}, + } + for _, v := range tt { + t.Run(fmt.Sprintf("IsPublicMemberOfOrgId%d", v.orgid), func(t *testing.T) { + testUserListIsPublicMember(t, v.orgid, v.expected) + }) + } +} + +func testUserListIsPublicMember(t *testing.T, orgID int64, expected map[int64]bool) { + org, err := organization.GetOrgByID(db.DefaultContext, orgID) + require.NoError(t, err) + _, membersIsPublic, err := org.GetMembers(db.DefaultContext) + require.NoError(t, err) + assert.Equal(t, expected, membersIsPublic) +} + +func TestUserListIsUserOrgOwner(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + tt := []struct { + orgid int64 + expected map[int64]bool + }{ + {3, map[int64]bool{2: true, 4: false, 28: false}}, + {6, map[int64]bool{5: true, 28: false}}, + {7, map[int64]bool{5: true}}, + {25, map[int64]bool{24: false}}, // ErrTeamNotExist + {22, map[int64]bool{}}, // No member + } + for _, v := range tt { + t.Run(fmt.Sprintf("IsUserOrgOwnerOfOrgId%d", v.orgid), func(t *testing.T) { + testUserListIsUserOrgOwner(t, v.orgid, v.expected) + }) + } +} + +func testUserListIsUserOrgOwner(t *testing.T, orgID int64, expected map[int64]bool) { + org, err := organization.GetOrgByID(db.DefaultContext, orgID) + require.NoError(t, err) + members, _, err := org.GetMembers(db.DefaultContext) + require.NoError(t, err) + assert.Equal(t, expected, organization.IsUserOrgOwner(db.DefaultContext, members, orgID)) +} + +func TestAddOrgUser(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + testSuccess := func(orgID, userID int64, isPublic bool) { + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: orgID}) + expectedNumMembers := org.NumMembers + if !unittest.BeanExists(t, &organization.OrgUser{OrgID: orgID, UID: userID}) { + expectedNumMembers++ + } + require.NoError(t, organization.AddOrgUser(db.DefaultContext, orgID, userID)) + ou := &organization.OrgUser{OrgID: orgID, UID: userID} + unittest.AssertExistsAndLoadBean(t, ou) + assert.Equal(t, isPublic, ou.IsPublic) + org = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: orgID}) + assert.EqualValues(t, expectedNumMembers, org.NumMembers) + } + + setting.Service.DefaultOrgMemberVisible = false + testSuccess(3, 5, false) + testSuccess(3, 5, false) + testSuccess(6, 2, false) + + setting.Service.DefaultOrgMemberVisible = true + testSuccess(6, 3, true) + + unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{}) +} diff --git a/models/organization/team.go b/models/organization/team.go new file mode 100644 index 0000000..ddff32c --- /dev/null +++ b/models/organization/team.go @@ -0,0 +1,310 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2016 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// ___________ +// \__ ___/___ _____ _____ +// | |_/ __ \\__ \ / \ +// | |\ ___/ / __ \| Y Y \ +// |____| \___ >____ /__|_| / +// \/ \/ \/ + +// ErrTeamAlreadyExist represents a "TeamAlreadyExist" kind of error. +type ErrTeamAlreadyExist struct { + OrgID int64 + Name string +} + +// IsErrTeamAlreadyExist checks if an error is a ErrTeamAlreadyExist. +func IsErrTeamAlreadyExist(err error) bool { + _, ok := err.(ErrTeamAlreadyExist) + return ok +} + +func (err ErrTeamAlreadyExist) Error() string { + return fmt.Sprintf("team already exists [org_id: %d, name: %s]", err.OrgID, err.Name) +} + +func (err ErrTeamAlreadyExist) Unwrap() error { + return util.ErrAlreadyExist +} + +// ErrTeamNotExist represents a "TeamNotExist" error +type ErrTeamNotExist struct { + OrgID int64 + TeamID int64 + Name string +} + +// IsErrTeamNotExist checks if an error is a ErrTeamNotExist. +func IsErrTeamNotExist(err error) bool { + _, ok := err.(ErrTeamNotExist) + return ok +} + +func (err ErrTeamNotExist) Error() string { + return fmt.Sprintf("team does not exist [org_id %d, team_id %d, name: %s]", err.OrgID, err.TeamID, err.Name) +} + +func (err ErrTeamNotExist) Unwrap() error { + return util.ErrNotExist +} + +// OwnerTeamName return the owner team name +const OwnerTeamName = "Owners" + +// Team represents a organization team. +type Team struct { + ID int64 `xorm:"pk autoincr"` + OrgID int64 `xorm:"INDEX"` + LowerName string + Name string + Description string + AccessMode perm.AccessMode `xorm:"'authorize'"` + Repos []*repo_model.Repository `xorm:"-"` + Members []*user_model.User `xorm:"-"` + NumRepos int + NumMembers int + Units []*TeamUnit `xorm:"-"` + IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"` + CanCreateOrgRepo bool `xorm:"NOT NULL DEFAULT false"` +} + +func init() { + db.RegisterModel(new(Team)) + db.RegisterModel(new(TeamUser)) + db.RegisterModel(new(TeamRepo)) + db.RegisterModel(new(TeamUnit)) + db.RegisterModel(new(TeamInvite)) +} + +func (t *Team) LogString() string { + if t == nil { + return "<Team nil>" + } + return fmt.Sprintf("<Team %d:%s OrgID=%d AccessMode=%s>", t.ID, t.Name, t.OrgID, t.AccessMode.LogString()) +} + +// LoadUnits load a list of available units for a team +func (t *Team) LoadUnits(ctx context.Context) (err error) { + if t.Units != nil { + return nil + } + + t.Units, err = getUnitsByTeamID(ctx, t.ID) + return err +} + +// GetUnitNames returns the team units names +func (t *Team) GetUnitNames() (res []string) { + if t.AccessMode >= perm.AccessModeAdmin { + return unit.AllUnitKeyNames() + } + + for _, u := range t.Units { + res = append(res, unit.Units[u.Type].NameKey) + } + return res +} + +// GetUnitsMap returns the team units permissions +func (t *Team) GetUnitsMap() map[string]string { + m := make(map[string]string) + if t.AccessMode >= perm.AccessModeAdmin { + for _, u := range unit.Units { + m[u.NameKey] = t.AccessMode.String() + } + } else { + for _, u := range t.Units { + m[u.Unit().NameKey] = u.AccessMode.String() + } + } + return m +} + +// IsOwnerTeam returns true if team is owner team. +func (t *Team) IsOwnerTeam() bool { + return t.Name == OwnerTeamName +} + +// IsMember returns true if given user is a member of team. +func (t *Team) IsMember(ctx context.Context, userID int64) bool { + isMember, err := IsTeamMember(ctx, t.OrgID, t.ID, userID) + if err != nil { + log.Error("IsMember: %v", err) + return false + } + return isMember +} + +// LoadRepositories returns paginated repositories in team of organization. +func (t *Team) LoadRepositories(ctx context.Context) (err error) { + if t.Repos != nil { + return nil + } + t.Repos, err = GetTeamRepositories(ctx, &SearchTeamRepoOptions{ + TeamID: t.ID, + }) + return err +} + +// LoadMembers returns paginated members in team of organization. +func (t *Team) LoadMembers(ctx context.Context) (err error) { + t.Members, err = GetTeamMembers(ctx, &SearchMembersOptions{ + TeamID: t.ID, + }) + return err +} + +// UnitEnabled returns if the team has the given unit type enabled +func (t *Team) UnitEnabled(ctx context.Context, tp unit.Type) bool { + return t.UnitAccessMode(ctx, tp) > perm.AccessModeNone +} + +// UnitAccessMode returns if the team has the given unit type enabled +func (t *Team) UnitAccessMode(ctx context.Context, tp unit.Type) perm.AccessMode { + if err := t.LoadUnits(ctx); err != nil { + log.Warn("Error loading team (ID: %d) units: %s", t.ID, err.Error()) + } + + for _, unit := range t.Units { + if unit.Type == tp { + return unit.AccessMode + } + } + return perm.AccessModeNone +} + +// IsUsableTeamName tests if a name could be as team name +func IsUsableTeamName(name string) error { + switch name { + case "new": + return db.ErrNameReserved{Name: name} + default: + return nil + } +} + +// GetTeam returns team by given team name and organization. +func GetTeam(ctx context.Context, orgID int64, name string) (*Team, error) { + t, exist, err := db.Get[Team](ctx, builder.Eq{"org_id": orgID, "lower_name": strings.ToLower(name)}) + if err != nil { + return nil, err + } else if !exist { + return nil, ErrTeamNotExist{orgID, 0, name} + } + return t, nil +} + +// GetTeamIDsByNames returns a slice of team ids corresponds to names. +func GetTeamIDsByNames(ctx context.Context, orgID int64, names []string, ignoreNonExistent bool) ([]int64, error) { + ids := make([]int64, 0, len(names)) + for _, name := range names { + u, err := GetTeam(ctx, orgID, name) + if err != nil { + if ignoreNonExistent { + continue + } + return nil, err + } + ids = append(ids, u.ID) + } + return ids, nil +} + +// GetOwnerTeam returns team by given team name and organization. +func GetOwnerTeam(ctx context.Context, orgID int64) (*Team, error) { + return GetTeam(ctx, orgID, OwnerTeamName) +} + +// GetTeamByID returns team by given ID. +func GetTeamByID(ctx context.Context, teamID int64) (*Team, error) { + t := new(Team) + has, err := db.GetEngine(ctx).ID(teamID).Get(t) + if err != nil { + return nil, err + } else if !has { + return nil, ErrTeamNotExist{0, teamID, ""} + } + return t, nil +} + +// GetTeamNamesByID returns team's lower name from a list of team ids. +func GetTeamNamesByID(ctx context.Context, teamIDs []int64) ([]string, error) { + if len(teamIDs) == 0 { + return []string{}, nil + } + + var teamNames []string + err := db.GetEngine(ctx).Table("team"). + Select("lower_name"). + In("id", teamIDs). + Asc("name"). + Find(&teamNames) + + return teamNames, err +} + +// IncrTeamRepoNum increases the number of repos for the given team by 1 +func IncrTeamRepoNum(ctx context.Context, teamID int64) error { + _, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team)) + return err +} + +// CountInconsistentOwnerTeams returns the amount of owner teams that have all of +// their access modes set to "None". +func CountInconsistentOwnerTeams(ctx context.Context) (int64, error) { + return db.GetEngine(ctx).Table("team"). + Join("INNER", "team_unit", "`team`.id = `team_unit`.team_id"). + Where("`team`.lower_name = ?", strings.ToLower(OwnerTeamName)). + GroupBy("`team_unit`.team_id"). + Having("SUM(`team_unit`.access_mode) = 0"). + Count() +} + +// FixInconsistentOwnerTeams fixes inconsistent owner teams that have all of +// their access modes set to "None", it sets it back to "Owner". +func FixInconsistentOwnerTeams(ctx context.Context) (int64, error) { + teamIDs := []int64{} + if err := db.GetEngine(ctx).Table("team"). + Select("`team`.id"). + Join("INNER", "team_unit", "`team`.id = `team_unit`.team_id"). + Where("`team`.lower_name = ?", strings.ToLower(OwnerTeamName)). + GroupBy("`team_unit`.team_id"). + Having("SUM(`team_unit`.access_mode) = 0"). + Find(&teamIDs); err != nil { + return 0, err + } + + if err := db.Iterate(ctx, builder.In("team_id", teamIDs), func(ctx context.Context, bean *TeamUnit) error { + if bean.Type == unit.TypeExternalTracker || bean.Type == unit.TypeExternalWiki { + bean.AccessMode = perm.AccessModeRead + } else { + bean.AccessMode = perm.AccessModeOwner + } + _, err := db.GetEngine(ctx).ID(bean.ID).Table("team_unit").Cols("access_mode").Update(bean) + return err + }); err != nil { + return 0, err + } + + return int64(len(teamIDs)), nil +} diff --git a/models/organization/team_invite.go b/models/organization/team_invite.go new file mode 100644 index 0000000..17f6c59 --- /dev/null +++ b/models/organization/team_invite.go @@ -0,0 +1,161 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +type ErrTeamInviteAlreadyExist struct { + TeamID int64 + Email string +} + +func IsErrTeamInviteAlreadyExist(err error) bool { + _, ok := err.(ErrTeamInviteAlreadyExist) + return ok +} + +func (err ErrTeamInviteAlreadyExist) Error() string { + return fmt.Sprintf("team invite already exists [team_id: %d, email: %s]", err.TeamID, err.Email) +} + +func (err ErrTeamInviteAlreadyExist) Unwrap() error { + return util.ErrAlreadyExist +} + +type ErrTeamInviteNotFound struct { + Token string +} + +func IsErrTeamInviteNotFound(err error) bool { + _, ok := err.(ErrTeamInviteNotFound) + return ok +} + +func (err ErrTeamInviteNotFound) Error() string { + return fmt.Sprintf("team invite was not found [token: %s]", err.Token) +} + +func (err ErrTeamInviteNotFound) Unwrap() error { + return util.ErrNotExist +} + +// ErrUserEmailAlreadyAdded represents a "user by email already added to team" error. +type ErrUserEmailAlreadyAdded struct { + Email string +} + +// IsErrUserEmailAlreadyAdded checks if an error is a ErrUserEmailAlreadyAdded. +func IsErrUserEmailAlreadyAdded(err error) bool { + _, ok := err.(ErrUserEmailAlreadyAdded) + return ok +} + +func (err ErrUserEmailAlreadyAdded) Error() string { + return fmt.Sprintf("user with email already added [email: %s]", err.Email) +} + +func (err ErrUserEmailAlreadyAdded) Unwrap() error { + return util.ErrAlreadyExist +} + +// TeamInvite represents an invite to a team +type TeamInvite struct { + ID int64 `xorm:"pk autoincr"` + Token string `xorm:"UNIQUE(token) INDEX NOT NULL DEFAULT ''"` + InviterID int64 `xorm:"NOT NULL DEFAULT 0"` + OrgID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + TeamID int64 `xorm:"UNIQUE(team_mail) INDEX NOT NULL DEFAULT 0"` + Email string `xorm:"UNIQUE(team_mail) NOT NULL DEFAULT ''"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` +} + +func CreateTeamInvite(ctx context.Context, doer *user_model.User, team *Team, email string) (*TeamInvite, error) { + has, err := db.GetEngine(ctx).Exist(&TeamInvite{ + TeamID: team.ID, + Email: email, + }) + if err != nil { + return nil, err + } + if has { + return nil, ErrTeamInviteAlreadyExist{ + TeamID: team.ID, + Email: email, + } + } + + // check if the user is already a team member by email + exist, err := db.GetEngine(ctx). + Where(builder.Eq{ + "team_user.org_id": team.OrgID, + "team_user.team_id": team.ID, + "`user`.email": email, + }). + Join("INNER", "`user`", "`user`.id = team_user.uid"). + Table("team_user"). + Exist() + if err != nil { + return nil, err + } + + if exist { + return nil, ErrUserEmailAlreadyAdded{ + Email: email, + } + } + + token, err := util.CryptoRandomString(25) + if err != nil { + return nil, err + } + + invite := &TeamInvite{ + Token: token, + InviterID: doer.ID, + OrgID: team.OrgID, + TeamID: team.ID, + Email: email, + } + + return invite, db.Insert(ctx, invite) +} + +func RemoveInviteByID(ctx context.Context, inviteID, teamID int64) error { + _, err := db.DeleteByBean(ctx, &TeamInvite{ + ID: inviteID, + TeamID: teamID, + }) + return err +} + +func GetInvitesByTeamID(ctx context.Context, teamID int64) ([]*TeamInvite, error) { + invites := make([]*TeamInvite, 0, 10) + return invites, db.GetEngine(ctx). + Where("team_id=?", teamID). + Find(&invites) +} + +func GetInviteByToken(ctx context.Context, token string) (*TeamInvite, error) { + invite := &TeamInvite{} + + has, err := db.GetEngine(ctx).Where("token=?", token).Get(invite) + if err != nil { + return nil, err + } + if !has { + return nil, ErrTeamInviteNotFound{Token: token} + } + return invite, nil +} diff --git a/models/organization/team_invite_test.go b/models/organization/team_invite_test.go new file mode 100644 index 0000000..cbabf79 --- /dev/null +++ b/models/organization/team_invite_test.go @@ -0,0 +1,49 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTeamInvite(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + + t.Run("MailExistsInTeam", func(t *testing.T) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // user 2 already added to team 2, should result in error + _, err := organization.CreateTeamInvite(db.DefaultContext, user2, team, user2.Email) + require.Error(t, err) + }) + + t.Run("CreateAndRemove", func(t *testing.T) { + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + invite, err := organization.CreateTeamInvite(db.DefaultContext, user1, team, "org3@example.com") + assert.NotNil(t, invite) + require.NoError(t, err) + + // Shouldn't allow duplicate invite + _, err = organization.CreateTeamInvite(db.DefaultContext, user1, team, "org3@example.com") + require.Error(t, err) + + // should remove invite + require.NoError(t, organization.RemoveInviteByID(db.DefaultContext, invite.ID, invite.TeamID)) + + // invite should not exist + _, err = organization.GetInviteByToken(db.DefaultContext, invite.Token) + require.Error(t, err) + }) +} diff --git a/models/organization/team_list.go b/models/organization/team_list.go new file mode 100644 index 0000000..5b45429 --- /dev/null +++ b/models/organization/team_list.go @@ -0,0 +1,128 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "context" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + + "xorm.io/builder" +) + +type TeamList []*Team + +func (t TeamList) LoadUnits(ctx context.Context) error { + for _, team := range t { + if err := team.LoadUnits(ctx); err != nil { + return err + } + } + return nil +} + +func (t TeamList) UnitMaxAccess(tp unit.Type) perm.AccessMode { + maxAccess := perm.AccessModeNone + for _, team := range t { + if team.IsOwnerTeam() { + return perm.AccessModeOwner + } + for _, teamUnit := range team.Units { + if teamUnit.Type != tp { + continue + } + if teamUnit.AccessMode > maxAccess { + maxAccess = teamUnit.AccessMode + } + } + } + return maxAccess +} + +// SearchTeamOptions holds the search options +type SearchTeamOptions struct { + db.ListOptions + UserID int64 + Keyword string + OrgID int64 + IncludeDesc bool +} + +func (opts *SearchTeamOptions) toCond() builder.Cond { + cond := builder.NewCond() + + if len(opts.Keyword) > 0 { + lowerKeyword := strings.ToLower(opts.Keyword) + var keywordCond builder.Cond = builder.Like{"lower_name", lowerKeyword} + if opts.IncludeDesc { + keywordCond = keywordCond.Or(builder.Like{"LOWER(description)", lowerKeyword}) + } + cond = cond.And(keywordCond) + } + + if opts.OrgID > 0 { + cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID}) + } + + if opts.UserID > 0 { + cond = cond.And(builder.Eq{"team_user.uid": opts.UserID}) + } + + return cond +} + +// SearchTeam search for teams. Caller is responsible to check permissions. +func SearchTeam(ctx context.Context, opts *SearchTeamOptions) (TeamList, int64, error) { + sess := db.GetEngine(ctx) + + opts.SetDefaultValues() + cond := opts.toCond() + + if opts.UserID > 0 { + sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id") + } + sess = db.SetSessionPagination(sess, opts) + + teams := make([]*Team, 0, opts.PageSize) + count, err := sess.Where(cond).OrderBy("lower_name").FindAndCount(&teams) + if err != nil { + return nil, 0, err + } + + return teams, count, nil +} + +// GetRepoTeams gets the list of teams that has access to the repository +func GetRepoTeams(ctx context.Context, repo *repo_model.Repository) (teams TeamList, err error) { + return teams, db.GetEngine(ctx). + Join("INNER", "team_repo", "team_repo.team_id = team.id"). + Where("team.org_id = ?", repo.OwnerID). + And("team_repo.repo_id=?", repo.ID). + OrderBy("CASE WHEN name LIKE '" + OwnerTeamName + "' THEN '' ELSE name END"). + Find(&teams) +} + +// GetUserOrgTeams returns all teams that user belongs to in given organization. +func GetUserOrgTeams(ctx context.Context, orgID, userID int64) (teams TeamList, err error) { + return teams, db.GetEngine(ctx). + Join("INNER", "team_user", "team_user.team_id = team.id"). + Where("team.org_id = ?", orgID). + And("team_user.uid=?", userID). + Find(&teams) +} + +// GetUserRepoTeams returns user repo's teams +func GetUserRepoTeams(ctx context.Context, orgID, userID, repoID int64) (teams TeamList, err error) { + return teams, db.GetEngine(ctx). + Join("INNER", "team_user", "team_user.team_id = team.id"). + Join("INNER", "team_repo", "team_repo.team_id = team.id"). + Where("team.org_id = ?", orgID). + And("team_user.uid=?", userID). + And("team_repo.repo_id=?", repoID). + Find(&teams) +} diff --git a/models/organization/team_repo.go b/models/organization/team_repo.go new file mode 100644 index 0000000..1184e39 --- /dev/null +++ b/models/organization/team_repo.go @@ -0,0 +1,85 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + + "xorm.io/builder" +) + +// TeamRepo represents an team-repository relation. +type TeamRepo struct { + ID int64 `xorm:"pk autoincr"` + OrgID int64 `xorm:"INDEX"` + TeamID int64 `xorm:"UNIQUE(s)"` + RepoID int64 `xorm:"UNIQUE(s)"` +} + +// HasTeamRepo returns true if given repository belongs to team. +func HasTeamRepo(ctx context.Context, orgID, teamID, repoID int64) bool { + has, _ := db.GetEngine(ctx). + Where("org_id=?", orgID). + And("team_id=?", teamID). + And("repo_id=?", repoID). + Get(new(TeamRepo)) + return has +} + +type SearchTeamRepoOptions struct { + db.ListOptions + TeamID int64 +} + +// GetRepositories returns paginated repositories in team of organization. +func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (repo_model.RepositoryList, error) { + sess := db.GetEngine(ctx) + if opts.TeamID > 0 { + sess = sess.In("id", + builder.Select("repo_id"). + From("team_repo"). + Where(builder.Eq{"team_id": opts.TeamID}), + ) + } + if opts.PageSize > 0 { + sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) + } + var repos []*repo_model.Repository + return repos, sess.OrderBy("repository.name"). + Find(&repos) +} + +// AddTeamRepo adds a repo for an organization's team +func AddTeamRepo(ctx context.Context, orgID, teamID, repoID int64) error { + _, err := db.GetEngine(ctx).Insert(&TeamRepo{ + OrgID: orgID, + TeamID: teamID, + RepoID: repoID, + }) + return err +} + +// RemoveTeamRepo remove repository from team +func RemoveTeamRepo(ctx context.Context, teamID, repoID int64) error { + _, err := db.DeleteByBean(ctx, &TeamRepo{ + TeamID: teamID, + RepoID: repoID, + }) + return err +} + +// GetTeamsWithAccessToRepo returns all teams in an organization that have given access level to the repository. +func GetTeamsWithAccessToRepo(ctx context.Context, orgID, repoID int64, mode perm.AccessMode) ([]*Team, error) { + teams := make([]*Team, 0, 5) + return teams, db.GetEngine(ctx).Where("team.authorize >= ?", mode). + Join("INNER", "team_repo", "team_repo.team_id = team.id"). + And("team_repo.org_id = ?", orgID). + And("team_repo.repo_id = ?", repoID). + OrderBy("name"). + Find(&teams) +} diff --git a/models/organization/team_test.go b/models/organization/team_test.go new file mode 100644 index 0000000..601d136 --- /dev/null +++ b/models/organization/team_test.go @@ -0,0 +1,250 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization_test + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTeam_IsOwnerTeam(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1}) + assert.True(t, team.IsOwnerTeam()) + + team = unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + assert.False(t, team.IsOwnerTeam()) +} + +func TestTeam_IsMember(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1}) + assert.True(t, team.IsMember(db.DefaultContext, 2)) + assert.False(t, team.IsMember(db.DefaultContext, 4)) + assert.False(t, team.IsMember(db.DefaultContext, unittest.NonexistentID)) + + team = unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + assert.True(t, team.IsMember(db.DefaultContext, 2)) + assert.True(t, team.IsMember(db.DefaultContext, 4)) + assert.False(t, team.IsMember(db.DefaultContext, unittest.NonexistentID)) +} + +func TestTeam_GetRepositories(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + test := func(teamID int64) { + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) + require.NoError(t, team.LoadRepositories(db.DefaultContext)) + assert.Len(t, team.Repos, team.NumRepos) + for _, repo := range team.Repos { + unittest.AssertExistsAndLoadBean(t, &organization.TeamRepo{TeamID: teamID, RepoID: repo.ID}) + } + } + test(1) + test(3) +} + +func TestTeam_GetMembers(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + test := func(teamID int64) { + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) + require.NoError(t, team.LoadMembers(db.DefaultContext)) + assert.Len(t, team.Members, team.NumMembers) + for _, member := range team.Members { + unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{UID: member.ID, TeamID: teamID}) + } + } + test(1) + test(3) +} + +func TestGetTeam(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + testSuccess := func(orgID int64, name string) { + team, err := organization.GetTeam(db.DefaultContext, orgID, name) + require.NoError(t, err) + assert.EqualValues(t, orgID, team.OrgID) + assert.Equal(t, name, team.Name) + } + testSuccess(3, "Owners") + testSuccess(3, "team1") + + _, err := organization.GetTeam(db.DefaultContext, 3, "nonexistent") + require.Error(t, err) + _, err = organization.GetTeam(db.DefaultContext, unittest.NonexistentID, "Owners") + require.Error(t, err) +} + +func TestGetTeamByID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + testSuccess := func(teamID int64) { + team, err := organization.GetTeamByID(db.DefaultContext, teamID) + require.NoError(t, err) + assert.EqualValues(t, teamID, team.ID) + } + testSuccess(1) + testSuccess(2) + testSuccess(3) + testSuccess(4) + + _, err := organization.GetTeamByID(db.DefaultContext, unittest.NonexistentID) + require.Error(t, err) +} + +func TestIsTeamMember(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + test := func(orgID, teamID, userID int64, expected bool) { + isMember, err := organization.IsTeamMember(db.DefaultContext, orgID, teamID, userID) + require.NoError(t, err) + assert.Equal(t, expected, isMember) + } + + test(3, 1, 2, true) + test(3, 1, 4, false) + test(3, 1, unittest.NonexistentID, false) + + test(3, 2, 2, true) + test(3, 2, 4, true) + + test(3, unittest.NonexistentID, unittest.NonexistentID, false) + test(unittest.NonexistentID, unittest.NonexistentID, unittest.NonexistentID, false) +} + +func TestGetTeamMembers(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + test := func(teamID int64) { + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) + members, err := organization.GetTeamMembers(db.DefaultContext, &organization.SearchMembersOptions{ + TeamID: teamID, + }) + require.NoError(t, err) + assert.Len(t, members, team.NumMembers) + for _, member := range members { + unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{UID: member.ID, TeamID: teamID}) + } + } + test(1) + test(3) +} + +func TestGetUserTeams(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + test := func(userID int64) { + teams, _, err := organization.SearchTeam(db.DefaultContext, &organization.SearchTeamOptions{UserID: userID}) + require.NoError(t, err) + for _, team := range teams { + unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{TeamID: team.ID, UID: userID}) + } + } + test(2) + test(5) + test(unittest.NonexistentID) +} + +func TestGetUserOrgTeams(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + test := func(orgID, userID int64) { + teams, err := organization.GetUserOrgTeams(db.DefaultContext, orgID, userID) + require.NoError(t, err) + for _, team := range teams { + assert.EqualValues(t, orgID, team.OrgID) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{TeamID: team.ID, UID: userID}) + } + } + test(3, 2) + test(3, 4) + test(3, unittest.NonexistentID) +} + +func TestHasTeamRepo(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + test := func(teamID, repoID int64, expected bool) { + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) + assert.Equal(t, expected, organization.HasTeamRepo(db.DefaultContext, team.OrgID, teamID, repoID)) + } + test(1, 1, false) + test(1, 3, true) + test(1, 5, true) + test(1, unittest.NonexistentID, false) + + test(2, 3, true) + test(2, 5, false) +} + +func TestUsersInTeamsCount(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + test := func(teamIDs, userIDs []int64, expected int64) { + count, err := organization.UsersInTeamsCount(db.DefaultContext, teamIDs, userIDs) + require.NoError(t, err) + assert.Equal(t, expected, count) + } + + test([]int64{2}, []int64{1, 2, 3, 4}, 1) // only userid 2 + 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 TestInconsistentOwnerTeam(t *testing.T) { + defer unittest.OverrideFixtures( + unittest.FixturesOptions{ + Dir: filepath.Join(setting.AppWorkPath, "models/fixtures/"), + Base: setting.AppWorkPath, + Dirs: []string{"models/organization/TestInconsistentOwnerTeam/"}, + }, + )() + require.NoError(t, unittest.PrepareTestDatabase()) + + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1000, TeamID: 1000, AccessMode: perm.AccessModeNone}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1001, TeamID: 1000, AccessMode: perm.AccessModeNone}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1002, TeamID: 1000, AccessMode: perm.AccessModeNone}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1003, TeamID: 1000, AccessMode: perm.AccessModeNone}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1004, TeamID: 1000, AccessMode: perm.AccessModeNone}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1005, TeamID: 1000, AccessMode: perm.AccessModeNone}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1006, TeamID: 1000, AccessMode: perm.AccessModeNone}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1007, TeamID: 1000, AccessMode: perm.AccessModeNone}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1008, TeamID: 1000, AccessMode: perm.AccessModeNone}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1009, TeamID: 1000, AccessMode: perm.AccessModeNone}) + + count, err := organization.CountInconsistentOwnerTeams(db.DefaultContext) + require.NoError(t, err) + require.EqualValues(t, 1, count) + + count, err = organization.FixInconsistentOwnerTeams(db.DefaultContext) + require.NoError(t, err) + require.EqualValues(t, 1, count) + + count, err = organization.CountInconsistentOwnerTeams(db.DefaultContext) + require.NoError(t, err) + require.EqualValues(t, 0, count) + + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1000, AccessMode: perm.AccessModeOwner}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1001, AccessMode: perm.AccessModeOwner}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1002, AccessMode: perm.AccessModeOwner}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1003, AccessMode: perm.AccessModeOwner}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1004, AccessMode: perm.AccessModeOwner}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1007, AccessMode: perm.AccessModeOwner}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1008, AccessMode: perm.AccessModeOwner}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1009, AccessMode: perm.AccessModeOwner}) + + // External wiki and issue + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1005, AccessMode: perm.AccessModeRead}) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ID: 1006, AccessMode: perm.AccessModeRead}) +} diff --git a/models/organization/team_unit.go b/models/organization/team_unit.go new file mode 100644 index 0000000..3087b70 --- /dev/null +++ b/models/organization/team_unit.go @@ -0,0 +1,51 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/unit" +) + +// TeamUnit describes all units of a repository +type TeamUnit struct { + ID int64 `xorm:"pk autoincr"` + OrgID int64 `xorm:"INDEX"` + TeamID int64 `xorm:"UNIQUE(s)"` + Type unit.Type `xorm:"UNIQUE(s)"` + AccessMode perm.AccessMode +} + +// Unit returns Unit +func (t *TeamUnit) Unit() unit.Unit { + return unit.Units[t.Type] +} + +func getUnitsByTeamID(ctx context.Context, teamID int64) (units []*TeamUnit, err error) { + return units, db.GetEngine(ctx).Where("team_id = ?", teamID).Find(&units) +} + +// UpdateTeamUnits updates a teams's units +func UpdateTeamUnits(ctx context.Context, team *Team, units []TeamUnit) (err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if _, err = db.GetEngine(ctx).Where("team_id = ?", team.ID).Delete(new(TeamUnit)); err != nil { + return err + } + + if len(units) > 0 { + if err = db.Insert(ctx, units); err != nil { + return err + } + } + + return committer.Commit() +} diff --git a/models/organization/team_user.go b/models/organization/team_user.go new file mode 100644 index 0000000..ab767db --- /dev/null +++ b/models/organization/team_user.go @@ -0,0 +1,89 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "context" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + + "xorm.io/builder" +) + +// TeamUser represents an team-user relation. +type TeamUser struct { + ID int64 `xorm:"pk autoincr"` + OrgID int64 `xorm:"INDEX"` + TeamID int64 `xorm:"UNIQUE(s)"` + UID int64 `xorm:"UNIQUE(s)"` +} + +// IsTeamMember returns true if given user is a member of team. +func IsTeamMember(ctx context.Context, orgID, teamID, userID int64) (bool, error) { + return db.GetEngine(ctx). + Where("org_id=?", orgID). + And("team_id=?", teamID). + And("uid=?", userID). + Table("team_user"). + Exist() +} + +// GetTeamUsersByTeamID returns team users for a team +func GetTeamUsersByTeamID(ctx context.Context, teamID int64) ([]*TeamUser, error) { + teamUsers := make([]*TeamUser, 0, 10) + return teamUsers, db.GetEngine(ctx). + Where("team_id=?", teamID). + Find(&teamUsers) +} + +// SearchMembersOptions holds the search options +type SearchMembersOptions struct { + db.ListOptions + TeamID int64 +} + +func (opts SearchMembersOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opts.TeamID > 0 { + cond = cond.And(builder.Eq{"": opts.TeamID}) + } + return cond +} + +// GetTeamMembers returns all members in given team of organization. +func GetTeamMembers(ctx context.Context, opts *SearchMembersOptions) ([]*user_model.User, error) { + var members []*user_model.User + sess := db.GetEngine(ctx) + if opts.TeamID > 0 { + sess = sess.In("id", + builder.Select("uid"). + From("team_user"). + Where(builder.Eq{"team_id": opts.TeamID}), + ) + } + if opts.PageSize > 0 && opts.Page > 0 { + sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize) + } + if err := sess.OrderBy("full_name, name").Find(&members); err != nil { + return nil, err + } + return members, nil +} + +// IsUserInTeams returns if a user in some teams +func IsUserInTeams(ctx context.Context, userID int64, teamIDs []int64) (bool, error) { + return db.GetEngine(ctx).Where("uid=?", userID).In("team_id", teamIDs).Exist(new(TeamUser)) +} + +// UsersInTeamsCount counts the number of users which are in userIDs and teamIDs +func UsersInTeamsCount(ctx context.Context, userIDs, teamIDs []int64) (int64, error) { + var ids []int64 + if err := db.GetEngine(ctx).In("uid", userIDs).In("team_id", teamIDs). + Table("team_user"). + Cols("uid").GroupBy("uid").Find(&ids); err != nil { + return 0, err + } + return int64(len(ids)), nil +} |