From 1751d5fcf200b7d78ec5543fa620174c69d2746a Mon Sep 17 00:00:00 2001 From: Manush Dodunekov Date: Mon, 13 Jan 2020 19:33:46 +0200 Subject: [PATCH] Restricted users (#6274) * Restricted users (#4334): initial implementation * Add User.IsRestricted & UI to edit it * Pass user object instead of user id to places where IsRestricted flag matters * Restricted users: maintain access rows for all referenced repos (incl public) * Take logged in user & IsRestricted flag into account in org/repo listings, searches and accesses * Add basic repo access tests for restricted users Signed-off-by: Manush Dodunekov * Mention restricted users in the faq Signed-off-by: Manush Dodunekov * Revert unnecessary change `.isUserPartOfOrg` -> `.IsUserPartOfOrg` Signed-off-by: Manush Dodunekov * Remove unnecessary `org.IsOrganization()` call Signed-off-by: Manush Dodunekov * Revert to an `int64` keyed `accessMap` * Add type `userAccess` * Add convenience func updateUserAccess() * Turn accessMap into a `map[int64]userAccess` Signed-off-by: Manush Dodunekov * or even better: `map[int64]*userAccess` * updateUserAccess(): use tighter syntax as suggested by lafriks * even tighter * Avoid extra loop * Don't disclose limited orgs to unauthenticated users * Don't assume block only applies to orgs * Use an array of `VisibleType` for filtering * fix yet another thinko * Ok - no need for u * Revert "Ok - no need for u" This reverts commit 5c3e886aabd5acd997a3b35687d322439732c200. Co-authored-by: Antoine GIRARD Co-authored-by: Lauris BH --- docs/content/doc/help/faq.en-us.md | 9 +++++ models/access.go | 49 ++++++++++++++++------ models/access_test.go | 50 +++++++++++++++++++++++ models/action.go | 20 ++++++--- models/action_test.go | 40 +++++++++--------- models/fixtures/access.yml | 14 ++++++- models/fixtures/collaboration.yml | 8 +++- models/fixtures/org_user.yml | 5 +++ models/fixtures/team.yml | 2 +- models/fixtures/team_user.yml | 6 +++ models/fixtures/user.yml | 17 +++++++- models/lfs.go | 4 +- models/migrations/migrations.go | 2 + models/migrations/v121.go | 17 ++++++++ models/org.go | 25 +++++++++--- models/repo_list.go | 65 +++++++++++++++++------------- models/repo_permission.go | 8 ++-- models/user.go | 19 ++++++--- models/user_heatmap_test.go | 10 ++--- models/user_test.go | 4 +- modules/auth/admin.go | 1 + options/locale/locale_en-US.ini | 2 + routers/admin/users.go | 1 + routers/api/v1/repo/issue.go | 3 +- routers/api/v1/repo/repo.go | 3 +- routers/home.go | 31 +++++++------- routers/org/home.go | 3 +- routers/user/home.go | 1 + routers/user/profile.go | 7 ++-- templates/admin/user/edit.tmpl | 6 +++ templates/admin/user/list.tmpl | 2 + 31 files changed, 310 insertions(+), 124 deletions(-) create mode 100644 models/migrations/v121.go diff --git a/docs/content/doc/help/faq.en-us.md b/docs/content/doc/help/faq.en-us.md index 8a65b522f..2a1e3e6a6 100644 --- a/docs/content/doc/help/faq.en-us.md +++ b/docs/content/doc/help/faq.en-us.md @@ -31,6 +31,7 @@ Also see [Support Options]({{< relref "doc/help/seek-help.en-us.md" >}}) * [Only allow certain email domains](#only-allow-certain-email-domains) * [Only allow/block certain OpenID providers](#only-allow-block-certain-openid-providers) * [Issue only users](#issue-only-users) + * [Restricted users](#restricted-users) * [Enable Fail2ban](#enable-fail2ban) * [Adding custom themes](#how-to-add-use-custom-themes) * [SSHD vs built-in SSH](#sshd-vs-built-in-ssh) @@ -147,6 +148,14 @@ You can configure `WHITELISTED_URIS` or `BLACKLISTED_URIS` under `[openid]` in y ### Issue only users The current way to achieve this is to create/modify a user with a max repo creation limit of 0. +### Restricted users +Restricted users are limited to a subset of the content based on their organization/team memberships and collaborations, ignoring the public flag on organizations/repos etc.__ + +Example use case: A company runs a Gitea instance that requires login. Most repos are public (accessible/browseable by all co-workers). + +At some point, a customer or third party needs access to a specific repo and only that repo. Making such a customer account restricted and granting any needed access using team membership(s) and/or collaboration(s) is a simple way to achieve that without the need to make everything private. + + ### Enable Fail2ban Use [Fail2Ban]({{ relref "doc/usage/fail2ban-setup.md" >}}) to monitor and stop automated login attempts or other malicious behavior based on log patterns diff --git a/models/access.go b/models/access.go index 213efe08a..94defbb19 100644 --- a/models/access.go +++ b/models/access.go @@ -71,9 +71,17 @@ type Access struct { Mode AccessMode } -func accessLevel(e Engine, userID int64, repo *Repository) (AccessMode, error) { +func accessLevel(e Engine, user *User, repo *Repository) (AccessMode, error) { mode := AccessModeNone - if !repo.IsPrivate { + var userID int64 + restricted := false + + if user != nil { + userID = user.ID + restricted = user.IsRestricted + } + + if !restricted && !repo.IsPrivate { mode = AccessModeRead } @@ -162,22 +170,37 @@ func maxAccessMode(modes ...AccessMode) AccessMode { return max } +type userAccess struct { + User *User + Mode AccessMode +} + +// updateUserAccess updates an access map so that user has at least mode +func updateUserAccess(accessMap map[int64]*userAccess, user *User, mode AccessMode) { + if ua, ok := accessMap[user.ID]; ok { + ua.Mode = maxAccessMode(ua.Mode, mode) + } else { + accessMap[user.ID] = &userAccess{User: user, Mode: mode} + } +} + // FIXME: do cross-comparison so reduce deletions and additions to the minimum? -func (repo *Repository) refreshAccesses(e Engine, accessMap map[int64]AccessMode) (err error) { +func (repo *Repository) refreshAccesses(e Engine, accessMap map[int64]*userAccess) (err error) { minMode := AccessModeRead if !repo.IsPrivate { minMode = AccessModeWrite } newAccesses := make([]Access, 0, len(accessMap)) - for userID, mode := range accessMap { - if mode < minMode { + for userID, ua := range accessMap { + if ua.Mode < minMode && !ua.User.IsRestricted { continue } + newAccesses = append(newAccesses, Access{ UserID: userID, RepoID: repo.ID, - Mode: mode, + Mode: ua.Mode, }) } @@ -191,13 +214,13 @@ func (repo *Repository) refreshAccesses(e Engine, accessMap map[int64]AccessMode } // refreshCollaboratorAccesses retrieves repository collaborations with their access modes. -func (repo *Repository) refreshCollaboratorAccesses(e Engine, accessMap map[int64]AccessMode) error { - collaborations, err := repo.getCollaborations(e) +func (repo *Repository) refreshCollaboratorAccesses(e Engine, accessMap map[int64]*userAccess) error { + collaborators, err := repo.getCollaborators(e) if err != nil { return fmt.Errorf("getCollaborations: %v", err) } - for _, c := range collaborations { - accessMap[c.UserID] = c.Mode + for _, c := range collaborators { + updateUserAccess(accessMap, c.User, c.Collaboration.Mode) } return nil } @@ -206,7 +229,7 @@ func (repo *Repository) refreshCollaboratorAccesses(e Engine, accessMap map[int6 // except the team whose ID is given. It is used to assign a team ID when // remove repository from that team. func (repo *Repository) recalculateTeamAccesses(e Engine, ignTeamID int64) (err error) { - accessMap := make(map[int64]AccessMode, 20) + accessMap := make(map[int64]*userAccess, 20) if err = repo.getOwner(e); err != nil { return err @@ -239,7 +262,7 @@ func (repo *Repository) recalculateTeamAccesses(e Engine, ignTeamID int64) (err return fmt.Errorf("getMembers '%d': %v", t.ID, err) } for _, m := range t.Members { - accessMap[m.ID] = maxAccessMode(accessMap[m.ID], t.Authorize) + updateUserAccess(accessMap, m, t.Authorize) } } @@ -300,7 +323,7 @@ func (repo *Repository) recalculateAccesses(e Engine) error { return repo.recalculateTeamAccesses(e, 0) } - accessMap := make(map[int64]AccessMode, 20) + accessMap := make(map[int64]*userAccess, 20) if err := repo.refreshCollaboratorAccesses(e, accessMap); err != nil { return fmt.Errorf("refreshCollaboratorAccesses: %v", err) } diff --git a/models/access_test.go b/models/access_test.go index d0f003254..103fe3a68 100644 --- a/models/access_test.go +++ b/models/access_test.go @@ -15,6 +15,7 @@ func TestAccessLevel(t *testing.T) { user2 := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) user5 := AssertExistsAndLoadBean(t, &User{ID: 5}).(*User) + user29 := AssertExistsAndLoadBean(t, &User{ID: 29}).(*User) // A public repository owned by User 2 repo1 := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository) assert.False(t, repo1.IsPrivate) @@ -22,6 +23,12 @@ func TestAccessLevel(t *testing.T) { repo3 := AssertExistsAndLoadBean(t, &Repository{ID: 3}).(*Repository) assert.True(t, repo3.IsPrivate) + // Another public repository + repo4 := AssertExistsAndLoadBean(t, &Repository{ID: 4}).(*Repository) + assert.False(t, repo4.IsPrivate) + // org. owned private repo + repo24 := AssertExistsAndLoadBean(t, &Repository{ID: 24}).(*Repository) + level, err := AccessLevel(user2, repo1) assert.NoError(t, err) assert.Equal(t, AccessModeOwner, level) @@ -37,6 +44,21 @@ func TestAccessLevel(t *testing.T) { level, err = AccessLevel(user5, repo3) assert.NoError(t, err) assert.Equal(t, AccessModeNone, level) + + // restricted user has no access to a public repo + level, err = AccessLevel(user29, repo1) + assert.NoError(t, err) + assert.Equal(t, AccessModeNone, level) + + // ... unless he's a collaborator + level, err = AccessLevel(user29, repo4) + assert.NoError(t, err) + assert.Equal(t, AccessModeWrite, level) + + // ... or a team member + level, err = AccessLevel(user29, repo24) + assert.NoError(t, err) + assert.Equal(t, AccessModeRead, level) } func TestHasAccess(t *testing.T) { @@ -72,6 +94,11 @@ func TestUser_GetRepositoryAccesses(t *testing.T) { accesses, err := user1.GetRepositoryAccesses() assert.NoError(t, err) assert.Len(t, accesses, 0) + + user29 := AssertExistsAndLoadBean(t, &User{ID: 29}).(*User) + accesses, err = user29.GetRepositoryAccesses() + assert.NoError(t, err) + assert.Len(t, accesses, 2) } func TestUser_GetAccessibleRepositories(t *testing.T) { @@ -86,6 +113,11 @@ func TestUser_GetAccessibleRepositories(t *testing.T) { repos, err = user2.GetAccessibleRepositories(0) assert.NoError(t, err) assert.Len(t, repos, 1) + + user29 := AssertExistsAndLoadBean(t, &User{ID: 29}).(*User) + repos, err = user29.GetAccessibleRepositories(0) + assert.NoError(t, err) + assert.Len(t, repos, 2) } func TestRepository_RecalculateAccesses(t *testing.T) { @@ -119,3 +151,21 @@ func TestRepository_RecalculateAccesses2(t *testing.T) { assert.NoError(t, err) assert.False(t, has) } + +func TestRepository_RecalculateAccesses3(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + team5 := AssertExistsAndLoadBean(t, &Team{ID: 5}).(*Team) + user29 := AssertExistsAndLoadBean(t, &User{ID: 29}).(*User) + + has, err := x.Get(&Access{UserID: 29, RepoID: 23}) + assert.NoError(t, err) + assert.False(t, has) + + // adding user29 to team5 should add an explicit access row for repo 23 + // even though repo 23 is public + assert.NoError(t, AddTeamMember(team5, user29.ID)) + + has, err = x.Get(&Access{UserID: 29, RepoID: 23}) + assert.NoError(t, err) + assert.True(t, has) +} diff --git a/models/action.go b/models/action.go index 1754c2a35..1a6ff7560 100644 --- a/models/action.go +++ b/models/action.go @@ -284,11 +284,11 @@ func (a *Action) GetIssueContent() string { // GetFeedsOptions options for retrieving feeds type GetFeedsOptions struct { - RequestedUser *User - RequestingUserID int64 - IncludePrivate bool // include private actions - OnlyPerformedBy bool // only actions performed by requested user - IncludeDeleted bool // include deleted actions + RequestedUser *User // the user we want activity for + Actor *User // the user viewing the activity + IncludePrivate bool // include private actions + OnlyPerformedBy bool // only actions performed by requested user + IncludeDeleted bool // include deleted actions } // GetFeeds returns actions according to the provided options @@ -296,8 +296,14 @@ func GetFeeds(opts GetFeedsOptions) ([]*Action, error) { cond := builder.NewCond() var repoIDs []int64 + var actorID int64 + + if opts.Actor != nil { + actorID = opts.Actor.ID + } + if opts.RequestedUser.IsOrganization() { - env, err := opts.RequestedUser.AccessibleReposEnv(opts.RequestingUserID) + env, err := opts.RequestedUser.AccessibleReposEnv(actorID) if err != nil { return nil, fmt.Errorf("AccessibleReposEnv: %v", err) } @@ -306,6 +312,8 @@ func GetFeeds(opts GetFeedsOptions) ([]*Action, error) { } cond = cond.And(builder.In("repo_id", repoIDs)) + } else if opts.Actor != nil { + cond = cond.And(builder.In("repo_id", opts.Actor.AccessibleRepoIDsQuery())) } cond = cond.And(builder.Eq{"user_id": opts.RequestedUser.ID}) diff --git a/models/action_test.go b/models/action_test.go index a4e224853..ccdec8f53 100644 --- a/models/action_test.go +++ b/models/action_test.go @@ -33,11 +33,11 @@ func TestGetFeeds(t *testing.T) { user := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) actions, err := GetFeeds(GetFeedsOptions{ - RequestedUser: user, - RequestingUserID: user.ID, - IncludePrivate: true, - OnlyPerformedBy: false, - IncludeDeleted: true, + RequestedUser: user, + Actor: user, + IncludePrivate: true, + OnlyPerformedBy: false, + IncludeDeleted: true, }) assert.NoError(t, err) if assert.Len(t, actions, 1) { @@ -46,10 +46,10 @@ func TestGetFeeds(t *testing.T) { } actions, err = GetFeeds(GetFeedsOptions{ - RequestedUser: user, - RequestingUserID: user.ID, - IncludePrivate: false, - OnlyPerformedBy: false, + RequestedUser: user, + Actor: user, + IncludePrivate: false, + OnlyPerformedBy: false, }) assert.NoError(t, err) assert.Len(t, actions, 0) @@ -59,14 +59,14 @@ func TestGetFeeds2(t *testing.T) { // test with an organization user assert.NoError(t, PrepareTestDatabase()) org := AssertExistsAndLoadBean(t, &User{ID: 3}).(*User) - const userID = 2 // user2 is an owner of the organization + user := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) actions, err := GetFeeds(GetFeedsOptions{ - RequestedUser: org, - RequestingUserID: userID, - IncludePrivate: true, - OnlyPerformedBy: false, - IncludeDeleted: true, + RequestedUser: org, + Actor: user, + IncludePrivate: true, + OnlyPerformedBy: false, + IncludeDeleted: true, }) assert.NoError(t, err) assert.Len(t, actions, 1) @@ -76,11 +76,11 @@ func TestGetFeeds2(t *testing.T) { } actions, err = GetFeeds(GetFeedsOptions{ - RequestedUser: org, - RequestingUserID: userID, - IncludePrivate: false, - OnlyPerformedBy: false, - IncludeDeleted: true, + RequestedUser: org, + Actor: user, + IncludePrivate: false, + OnlyPerformedBy: false, + IncludeDeleted: true, }) assert.NoError(t, err) assert.Len(t, actions, 0) diff --git a/models/fixtures/access.yml b/models/fixtures/access.yml index af2c8a529..811720c8e 100644 --- a/models/fixtures/access.yml +++ b/models/fixtures/access.yml @@ -74,4 +74,16 @@ id: 13 user_id: 20 repo_id: 28 - mode: 4 # owner \ No newline at end of file + mode: 4 # owner + +- + id: 14 + user_id: 29 + repo_id: 4 + mode: 2 # write (collaborator) + +- + id: 15 + user_id: 29 + repo_id: 24 + mode: 1 # read diff --git a/models/fixtures/collaboration.yml b/models/fixtures/collaboration.yml index d32e288e4..82d46f38f 100644 --- a/models/fixtures/collaboration.yml +++ b/models/fixtures/collaboration.yml @@ -14,4 +14,10 @@ id: 3 repo_id: 40 user_id: 4 - mode: 2 # write \ No newline at end of file + mode: 2 # write + +- + id: 4 + repo_id: 4 + user_id: 29 + mode: 2 # write diff --git a/models/fixtures/org_user.yml b/models/fixtures/org_user.yml index 0b6a5e60a..a0bc4b9b4 100644 --- a/models/fixtures/org_user.yml +++ b/models/fixtures/org_user.yml @@ -58,3 +58,8 @@ org_id: 6 is_public: true +- + id: 11 + uid: 29 + org_id: 17 + is_public: true diff --git a/models/fixtures/team.yml b/models/fixtures/team.yml index b7e385617..9a8b0aff7 100644 --- a/models/fixtures/team.yml +++ b/models/fixtures/team.yml @@ -77,7 +77,7 @@ name: review_team authorize: 1 # read num_repos: 1 - num_members: 1 + num_members: 2 - id: 10 diff --git a/models/fixtures/team_user.yml b/models/fixtures/team_user.yml index d541156fe..8f21164df 100644 --- a/models/fixtures/team_user.yml +++ b/models/fixtures/team_user.yml @@ -81,3 +81,9 @@ org_id: 6 team_id: 13 uid: 28 + +- + id: 15 + org_id: 17 + team_id: 9 + uid: 29 diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index 09a027de7..640fd65bf 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -275,7 +275,7 @@ avatar_email: user17@example.com num_repos: 2 is_active: true - num_members: 2 + num_members: 3 num_teams: 3 - @@ -463,3 +463,18 @@ num_following: 0 is_active: true +- + id: 29 + lower_name: user29 + name: user29 + full_name: User 29 + email: user29@example.com + passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password + type: 0 # individual + salt: ZogKvWdyEx + is_admin: false + is_restricted: true + avatar: avatar29 + avatar_email: user29@example.com + num_repos: 0 + is_active: true diff --git a/models/lfs.go b/models/lfs.go index 5f5fe2ccf..854b715d5 100644 --- a/models/lfs.go +++ b/models/lfs.go @@ -159,7 +159,7 @@ func LFSObjectAccessible(user *User, oid string) (bool, error) { count, err := x.Count(&LFSMetaObject{Oid: oid}) return (count > 0), err } - cond := accessibleRepositoryCondition(user.ID) + cond := accessibleRepositoryCondition(user) count, err := x.Where(cond).Join("INNER", "repository", "`lfs_meta_object`.repository_id = `repository`.id").Count(&LFSMetaObject{Oid: oid}) return (count > 0), err } @@ -182,7 +182,7 @@ func LFSAutoAssociate(metas []*LFSMetaObject, user *User, repoID int64) error { cond := builder.NewCond() if !user.IsAdmin { cond = builder.In("`lfs_meta_object`.repository_id", - builder.Select("`repository`.id").From("repository").Where(accessibleRepositoryCondition(user.ID))) + builder.Select("`repository`.id").From("repository").Where(accessibleRepositoryCondition(user))) } newMetas := make([]*LFSMetaObject, 0, len(metas)) if err := sess.Cols("oid").Where(cond).In("oid", oids...).GroupBy("oid").Find(&newMetas); err != nil { diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 703c168b0..6bdec1dfb 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -296,6 +296,8 @@ var migrations = []Migration{ NewMigration("Fix migrated repositories' git service type", fixMigratedRepositoryServiceType), // v120 -> v121 NewMigration("Add owner_name on table repository", addOwnerNameOnRepository), + // v121 -> v122 + NewMigration("add is_restricted column for users table", addIsRestricted), } // Migrate database to current version diff --git a/models/migrations/v121.go b/models/migrations/v121.go new file mode 100644 index 000000000..c1ff7df3a --- /dev/null +++ b/models/migrations/v121.go @@ -0,0 +1,17 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import "xorm.io/xorm" + +func addIsRestricted(x *xorm.Engine) error { + // User see models/user.go + type User struct { + ID int64 `xorm:"pk autoincr"` + IsRestricted bool `xorm:"NOT NULL DEFAULT false"` + } + + return x.Sync2(new(User)) +} diff --git a/models/org.go b/models/org.go index dbc71761f..d79c0db84 100644 --- a/models/org.go +++ b/models/org.go @@ -432,7 +432,7 @@ func hasOrgVisible(e Engine, org *User, user *User) bool { return true } - if org.Visibility == structs.VisibleTypePrivate && !org.isUserPartOfOrg(e, user.ID) { + if (org.Visibility == structs.VisibleTypePrivate || user.IsRestricted) && !org.isUserPartOfOrg(e, user.ID) { return false } return true @@ -735,7 +735,7 @@ type AccessibleReposEnvironment interface { type accessibleReposEnv struct { org *User - userID int64 + user *User teamIDs []int64 e Engine keyword string @@ -749,13 +749,23 @@ func (org *User) AccessibleReposEnv(userID int64) (AccessibleReposEnvironment, e } func (org *User) accessibleReposEnv(e Engine, userID int64) (AccessibleReposEnvironment, error) { + var user *User + + if userID > 0 { + u, err := getUserByID(e, userID) + if err != nil { + return nil, err + } + user = u + } + teamIDs, err := org.getUserTeamIDs(e, userID) if err != nil { return nil, err } return &accessibleReposEnv{ org: org, - userID: userID, + user: user, teamIDs: teamIDs, e: e, orderBy: SearchOrderByRecentUpdated, @@ -763,9 +773,12 @@ func (org *User) accessibleReposEnv(e Engine, userID int64) (AccessibleReposEnvi } func (env *accessibleReposEnv) cond() builder.Cond { - var cond builder.Cond = builder.Eq{ - "`repository`.owner_id": env.org.ID, - "`repository`.is_private": false, + var cond = builder.NewCond() + 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)) diff --git a/models/repo_list.go b/models/repo_list.go index 7b48834db..45a506698 100644 --- a/models/repo_list.go +++ b/models/repo_list.go @@ -111,8 +111,7 @@ func (repos MirrorRepositoryList) LoadAttributes() error { // SearchRepoOptions holds the search options type SearchRepoOptions struct { - UserID int64 - UserIsAdmin bool + Actor *User Keyword string OwnerID int64 PriorityOwnerID int64 @@ -180,9 +179,9 @@ func SearchRepository(opts *SearchRepoOptions) (RepositoryList, int64, error) { var cond = builder.NewCond() if opts.Private { - if !opts.UserIsAdmin && opts.UserID != 0 && opts.UserID != opts.OwnerID { + if opts.Actor != nil && !opts.Actor.IsAdmin && opts.Actor.ID != opts.OwnerID { // OK we're in the context of a User - cond = cond.And(accessibleRepositoryCondition(opts.UserID)) + cond = cond.And(accessibleRepositoryCondition(opts.Actor)) } } else { // Not looking at private organisations @@ -276,6 +275,10 @@ func SearchRepository(opts *SearchRepoOptions) (RepositoryList, int64, error) { cond = cond.And(builder.Eq{"is_mirror": opts.Mirror == util.OptionalBoolTrue}) } + if opts.Actor != nil && opts.Actor.IsRestricted { + cond = cond.And(accessibleRepositoryCondition(opts.Actor)) + } + if len(opts.OrderBy) == 0 { opts.OrderBy = SearchOrderByAlphabetically } @@ -314,32 +317,43 @@ func SearchRepository(opts *SearchRepoOptions) (RepositoryList, int64, error) { } // accessibleRepositoryCondition takes a user a returns a condition for checking if a repository is accessible -func accessibleRepositoryCondition(userID int64) builder.Cond { - return builder.Or( +func accessibleRepositoryCondition(user *User) builder.Cond { + var cond = builder.NewCond() + + if user == nil || !user.IsRestricted { + orgVisibilityLimit := []structs.VisibleType{structs.VisibleTypePrivate} + if user == nil { + orgVisibilityLimit = append(orgVisibilityLimit, structs.VisibleTypeLimited) + } // 1. Be able to see all non-private repositories that either: - builder.And( + cond = cond.Or(builder.And( builder.Eq{"`repository`.is_private": false}, builder.Or( // A. Aren't in organisations __OR__ builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"type": UserTypeOrganization})), - // B. Isn't a private organisation. (Limited is OK because we're logged in) - builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"visibility": structs.VisibleTypePrivate}))), - ), + // B. Isn't a private organisation. Limited is OK as long as we're logged in. + builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.In("visibility", orgVisibilityLimit)))))) + } + + if user != nil { // 2. Be able to see all repositories that we have access to - builder.Or( + cond = cond.Or(builder.Or( builder.In("`repository`.id", builder.Select("repo_id"). From("`access`"). Where(builder.And( - builder.Eq{"user_id": userID}, + builder.Eq{"user_id": user.ID}, builder.Gt{"mode": int(AccessModeNone)}))), builder.In("`repository`.id", builder.Select("id"). From("`repository`"). - Where(builder.Eq{"owner_id": userID}))), + Where(builder.Eq{"owner_id": user.ID})))) // 3. Be able to see all repositories that we are in a team - builder.In("`repository`.id", builder.Select("`team_repo`.repo_id"). + cond = cond.Or(builder.In("`repository`.id", builder.Select("`team_repo`.repo_id"). From("team_repo"). - Where(builder.Eq{"`team_user`.uid": userID}). + Where(builder.Eq{"`team_user`.uid": user.ID}). Join("INNER", "team_user", "`team_user`.team_id = `team_repo`.team_id"))) + } + + return cond } // SearchRepositoryByName takes keyword and part of repository name to search, @@ -349,25 +363,18 @@ func SearchRepositoryByName(opts *SearchRepoOptions) (RepositoryList, int64, err return SearchRepository(opts) } -// FindUserAccessibleRepoIDs find all accessible repositories' ID by user's id -func FindUserAccessibleRepoIDs(userID int64) ([]int64, error) { - var accessCond builder.Cond = builder.Eq{"is_private": false} - - if userID > 0 { - accessCond = accessCond.Or( - builder.Eq{"owner_id": userID}, - builder.And( - builder.Expr("id IN (SELECT repo_id FROM `access` WHERE access.user_id = ?)", userID), - builder.Neq{"owner_id": userID}, - ), - ) - } +// AccessibleRepoIDsQuery queries accessible repository ids. Usable as a subquery wherever repo ids need to be filtered. +func (user *User) AccessibleRepoIDsQuery() *builder.Builder { + return builder.Select("id").From("repository").Where(accessibleRepositoryCondition(user)) +} +// FindUserAccessibleRepoIDs find all accessible repositories' ID by user's id +func FindUserAccessibleRepoIDs(user *User) ([]int64, error) { repoIDs := make([]int64, 0, 10) if err := x. Table("repository"). Cols("id"). - Where(accessCond). + Where(accessibleRepositoryCondition(user)). Find(&repoIDs); err != nil { return nil, fmt.Errorf("FindUserAccesibleRepoIDs: %v", err) } diff --git a/models/repo_permission.go b/models/repo_permission.go index cd2022491..0b3e5b341 100644 --- a/models/repo_permission.go +++ b/models/repo_permission.go @@ -202,7 +202,7 @@ func getUserRepoPermission(e Engine, repo *Repository, user *User) (perm Permiss } // plain user - perm.AccessMode, err = accessLevel(e, user.ID, repo) + perm.AccessMode, err = accessLevel(e, user, repo) if err != nil { return } @@ -250,8 +250,8 @@ func getUserRepoPermission(e Engine, repo *Repository, user *User) (perm Permiss } } - // for a public repo on an organization, user have read permission on non-team defined units. - if !found && !repo.IsPrivate { + // for a public repo on an organization, a non-restricted user has read permission on non-team defined units. + if !found && !repo.IsPrivate && !user.IsRestricted { if _, ok := perm.UnitsMode[u.Type]; !ok { perm.UnitsMode[u.Type] = AccessModeRead } @@ -284,7 +284,7 @@ func isUserRepoAdmin(e Engine, repo *Repository, user *User) (bool, error) { return true, nil } - mode, err := accessLevel(e, user.ID, repo) + mode, err := accessLevel(e, user, repo) if err != nil { return false, err } diff --git a/models/user.go b/models/user.go index dc8ae7e0f..ea1d11080 100644 --- a/models/user.go +++ b/models/user.go @@ -132,6 +132,7 @@ type User struct { // Permissions IsActive bool `xorm:"INDEX"` // Activate primary email IsAdmin bool + IsRestricted bool `xorm:"NOT NULL DEFAULT false"` AllowGitHook bool AllowImportLocal bool // Allow migrate repository by local path AllowCreateOrganization bool `xorm:"DEFAULT true"` @@ -641,7 +642,7 @@ func (u *User) GetOrgRepositoryIDs(units ...UnitType) ([]int64, error) { if err := x.Table("repository"). Cols("repository.id"). Join("INNER", "team_user", "repository.owner_id = team_user.org_id"). - Join("INNER", "team_repo", "repository.is_private != ? OR (team_user.team_id = team_repo.team_id AND repository.id = team_repo.repo_id)", true). + Join("INNER", "team_repo", "(? != ? and repository.is_private != ?) OR (team_user.team_id = team_repo.team_id AND repository.id = team_repo.repo_id)", true, u.IsRestricted, true). Where("team_user.uid = ?", u.ID). GroupBy("repository.id").Find(&ids); err != nil { return nil, err @@ -1470,7 +1471,7 @@ type SearchUserOptions struct { OrderBy SearchOrderBy Page int Visible []structs.VisibleType - OwnerID int64 // id of user for visibility calculation + Actor *User // The user doing the search PageSize int // Can be smaller than or equal to setting.UI.ExplorePagingNum IsActive util.OptionalBool SearchByEmail bool // Search by email as well as username/full name @@ -1498,7 +1499,7 @@ func (opts *SearchUserOptions) toConds() builder.Cond { cond = cond.And(builder.In("visibility", structs.VisibleTypePublic)) } - if opts.OwnerID > 0 { + if opts.Actor != nil { var exprCond builder.Cond if setting.Database.UseMySQL { exprCond = builder.Expr("org_user.org_id = user.id") @@ -1507,9 +1508,15 @@ func (opts *SearchUserOptions) toConds() builder.Cond { } else { exprCond = builder.Expr("org_user.org_id = \"user\".id") } - accessCond := builder.Or( - builder.In("id", builder.Select("org_id").From("org_user").LeftJoin("`user`", exprCond).Where(builder.And(builder.Eq{"uid": opts.OwnerID}, builder.Eq{"visibility": structs.VisibleTypePrivate}))), - builder.In("visibility", structs.VisibleTypePublic, structs.VisibleTypeLimited)) + var accessCond = builder.NewCond() + if !opts.Actor.IsRestricted { + accessCond = builder.Or( + builder.In("id", builder.Select("org_id").From("org_user").LeftJoin("`user`", exprCond).Where(builder.And(builder.Eq{"uid": opts.Actor.ID}, builder.Eq{"visibility": structs.VisibleTypePrivate}))), + builder.In("visibility", structs.VisibleTypePublic, structs.VisibleTypeLimited)) + } else { + // restricted users only see orgs they are a member of + accessCond = builder.In("id", builder.Select("org_id").From("org_user").LeftJoin("`user`", exprCond).Where(builder.And(builder.Eq{"uid": opts.Actor.ID}))) + } cond = cond.And(accessCond) } diff --git a/models/user_heatmap_test.go b/models/user_heatmap_test.go index f882b3524..c2825d9ff 100644 --- a/models/user_heatmap_test.go +++ b/models/user_heatmap_test.go @@ -30,11 +30,11 @@ func TestGetUserHeatmapDataByUser(t *testing.T) { // get the action for comparison actions, err := GetFeeds(GetFeedsOptions{ - RequestedUser: user, - RequestingUserID: user.ID, - IncludePrivate: true, - OnlyPerformedBy: false, - IncludeDeleted: true, + RequestedUser: user, + Actor: user, + IncludePrivate: true, + OnlyPerformedBy: false, + IncludeDeleted: true, }) assert.NoError(t, err) diff --git a/models/user_test.go b/models/user_test.go index 95f4d5d36..2232d5996 100644 --- a/models/user_test.go +++ b/models/user_test.go @@ -153,13 +153,13 @@ func TestSearchUsers(t *testing.T) { } testUserSuccess(&SearchUserOptions{OrderBy: "id ASC", Page: 1}, - []int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28}) + []int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29}) testUserSuccess(&SearchUserOptions{Page: 1, IsActive: util.OptionalBoolFalse}, []int64{9}) testUserSuccess(&SearchUserOptions{OrderBy: "id ASC", Page: 1, IsActive: util.OptionalBoolTrue}, - []int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 28}) + []int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 28, 29}) testUserSuccess(&SearchUserOptions{Keyword: "user1", OrderBy: "id ASC", Page: 1, IsActive: util.OptionalBoolTrue}, []int64{1, 10, 11, 12, 13, 14, 15, 16, 18}) diff --git a/modules/auth/admin.go b/modules/auth/admin.go index 6e225891d..975069a4b 100644 --- a/modules/auth/admin.go +++ b/modules/auth/admin.go @@ -37,6 +37,7 @@ type AdminEditUserForm struct { MaxRepoCreation int Active bool Admin bool + Restricted bool AllowGitHook bool AllowImportLocal bool AllowCreateOrganization bool diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 4dc0b9223..38db43a57 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1751,6 +1751,7 @@ users.new_account = Create User Account users.name = Username users.activated = Activated users.admin = Admin +users.restricted = Restricted users.repos = Repos users.created = Created users.last_login = Last Sign-In @@ -1769,6 +1770,7 @@ users.max_repo_creation_desc = (Enter -1 to use the global default limit.) users.is_activated = User Account Is Activated users.prohibit_login = Disable Sign-In users.is_admin = Is Administrator +users.is_restricted = Is Restricted users.allow_git_hook = May Create Git Hooks users.allow_import_local = May Import Local Repositories users.allow_create_organization = May Create Organizations diff --git a/routers/admin/users.go b/routers/admin/users.go index b5c7dbd38..71cda86cc 100644 --- a/routers/admin/users.go +++ b/routers/admin/users.go @@ -233,6 +233,7 @@ func EditUserPost(ctx *context.Context, form auth.AdminEditUserForm) { u.MaxRepoCreation = form.MaxRepoCreation u.IsActive = form.Active u.IsAdmin = form.Admin + u.IsRestricted = form.Restricted u.AllowGitHook = form.AllowGitHook u.AllowImportLocal = form.AllowImportLocal u.AllowCreateOrganization = form.AllowCreateOrganization diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 69b8a3699..1219ef2e4 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -73,13 +73,12 @@ func SearchIssues(ctx *context.APIContext) { AllPublic: true, TopicOnly: false, Collaborate: util.OptionalBoolNone, - UserIsAdmin: ctx.IsUserSiteAdmin(), OrderBy: models.SearchOrderByRecentUpdated, + Actor: ctx.User, } if ctx.IsSigned { opts.Private = true opts.AllLimited = true - opts.UserID = ctx.User.ID } issueCount := 0 for page := 1; ; page++ { diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index c7959c6db..9ae0c4af4 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -126,6 +126,7 @@ func Search(ctx *context.APIContext) { // "$ref": "#/responses/validationError" opts := &models.SearchRepoOptions{ + Actor: ctx.User, Keyword: strings.Trim(ctx.Query("q"), " "), OwnerID: ctx.QueryInt64("uid"), PriorityOwnerID: ctx.QueryInt64("priority_owner_id"), @@ -135,8 +136,6 @@ func Search(ctx *context.APIContext) { Collaborate: util.OptionalBoolNone, Private: ctx.IsSigned && (ctx.Query("private") == "" || ctx.QueryBool("private")), Template: util.OptionalBoolNone, - UserIsAdmin: ctx.IsUserSiteAdmin(), - UserID: ctx.Data["SignedUserID"].(int64), StarredByID: ctx.QueryInt64("starredBy"), IncludeDescription: ctx.QueryBool("includeDesc"), } diff --git a/routers/home.go b/routers/home.go index 0f59c9570..96e13cc68 100644 --- a/routers/home.go +++ b/routers/home.go @@ -72,10 +72,11 @@ func Home(ctx *context.Context) { // RepoSearchOptions when calling search repositories type RepoSearchOptions struct { - OwnerID int64 - Private bool - PageSize int - TplName base.TplName + OwnerID int64 + Private bool + Restricted bool + PageSize int + TplName base.TplName } var ( @@ -136,6 +137,7 @@ func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) { ctx.Data["TopicOnly"] = topicOnly repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ + Actor: ctx.User, Page: page, PageSize: opts.PageSize, OrderBy: orderBy, @@ -190,6 +192,7 @@ func RenderUserSearch(ctx *context.Context, opts *models.SearchUserOptions, tplN if opts.Page <= 1 { opts.Page = 1 } + opts.Actor = ctx.User var ( users []*models.User @@ -261,22 +264,16 @@ func ExploreOrganizations(ctx *context.Context) { ctx.Data["PageIsExploreOrganizations"] = true ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - var ownerID int64 - if ctx.User != nil && !ctx.User.IsAdmin { - ownerID = ctx.User.ID + visibleTypes := []structs.VisibleType{structs.VisibleTypePublic} + if ctx.User != nil { + visibleTypes = append(visibleTypes, structs.VisibleTypeLimited, structs.VisibleTypePrivate) } - opts := models.SearchUserOptions{ + RenderUserSearch(ctx, &models.SearchUserOptions{ Type: models.UserTypeOrganization, PageSize: setting.UI.ExplorePagingNum, - OwnerID: ownerID, - } - if ctx.User != nil { - opts.Visible = []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate} - } else { - opts.Visible = []structs.VisibleType{structs.VisibleTypePublic} - } - RenderUserSearch(ctx, &opts, tplExploreOrganizations) + Visible: visibleTypes, + }, tplExploreOrganizations) } // ExploreCode render explore code page @@ -310,7 +307,7 @@ func ExploreCode(ctx *context.Context) { // guest user or non-admin user if ctx.User == nil || !isAdmin { - repoIDs, err = models.FindUserAccessibleRepoIDs(userID) + repoIDs, err = models.FindUserAccessibleRepoIDs(ctx.User) if err != nil { ctx.ServerError("SearchResults", err) return diff --git a/routers/org/home.go b/routers/org/home.go index 9c24fe72f..2f461d861 100644 --- a/routers/org/home.go +++ b/routers/org/home.go @@ -80,8 +80,7 @@ func Home(ctx *context.Context) { OwnerID: org.ID, OrderBy: orderBy, Private: ctx.IsSigned, - UserIsAdmin: ctx.IsUserSiteAdmin(), - UserID: ctx.Data["SignedUserID"].(int64), + Actor: ctx.User, Page: page, IsProfile: true, PageSize: setting.UI.User.RepoPagingNum, diff --git a/routers/user/home.go b/routers/user/home.go index 512c60716..822452f1c 100644 --- a/routers/user/home.go +++ b/routers/user/home.go @@ -144,6 +144,7 @@ func Dashboard(ctx *context.Context) { retrieveFeeds(ctx, models.GetFeedsOptions{ RequestedUser: ctxUser, + Actor: ctx.User, IncludePrivate: true, OnlyPerformedBy: false, IncludeDeleted: false, diff --git a/routers/user/profile.go b/routers/user/profile.go index 90e832b53..b5933788d 100644 --- a/routers/user/profile.go +++ b/routers/user/profile.go @@ -161,6 +161,7 @@ func Profile(ctx *context.Context) { switch tab { case "activity": retrieveFeeds(ctx, models.GetFeedsOptions{RequestedUser: ctxUser, + Actor: ctx.User, IncludePrivate: showPrivate, OnlyPerformedBy: true, IncludeDeleted: false, @@ -171,11 +172,10 @@ func Profile(ctx *context.Context) { case "stars": ctx.Data["PageIsProfileStarList"] = true repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ + Actor: ctx.User, Keyword: keyword, OrderBy: orderBy, Private: ctx.IsSigned, - UserIsAdmin: ctx.IsUserSiteAdmin(), - UserID: ctx.Data["SignedUserID"].(int64), Page: page, PageSize: setting.UI.User.RepoPagingNum, StarredByID: ctxUser.ID, @@ -191,12 +191,11 @@ func Profile(ctx *context.Context) { total = int(count) default: repos, count, err = models.SearchRepository(&models.SearchRepoOptions{ + Actor: ctx.User, Keyword: keyword, OwnerID: ctxUser.ID, OrderBy: orderBy, Private: ctx.IsSigned, - UserIsAdmin: ctx.IsUserSiteAdmin(), - UserID: ctx.Data["SignedUserID"].(int64), Page: page, IsProfile: true, PageSize: setting.UI.User.RepoPagingNum, diff --git a/templates/admin/user/edit.tmpl b/templates/admin/user/edit.tmpl index b2ec622ca..da75cb506 100644 --- a/templates/admin/user/edit.tmpl +++ b/templates/admin/user/edit.tmpl @@ -83,6 +83,12 @@ +
+
+ + +
+
diff --git a/templates/admin/user/list.tmpl b/templates/admin/user/list.tmpl index 538f9b7fe..72b7ccd19 100644 --- a/templates/admin/user/list.tmpl +++ b/templates/admin/user/list.tmpl @@ -21,6 +21,7 @@ {{.i18n.Tr "email"}} {{.i18n.Tr "admin.users.activated"}} {{.i18n.Tr "admin.users.admin"}} + {{.i18n.Tr "admin.users.restricted"}} {{.i18n.Tr "admin.users.repos"}} {{.i18n.Tr "admin.users.created"}} {{.i18n.Tr "admin.users.last_login"}} @@ -35,6 +36,7 @@ + {{.NumRepos}} {{.CreatedUnix.FormatShort}} {{if .LastLoginUnix}}