* 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 <manush@stendahls.se>
* Mention restricted users in the faq
Signed-off-by: Manush Dodunekov <manush@stendahls.se>
* Revert unnecessary change `.isUserPartOfOrg` -> `.IsUserPartOfOrg`
Signed-off-by: Manush Dodunekov <manush@stendahls.se>
* Remove unnecessary `org.IsOrganization()` call
Signed-off-by: Manush Dodunekov <manush@stendahls.se>
* 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 <manush@stendahls.se>
* 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 5c3e886aab
.
Co-authored-by: Antoine GIRARD <sapk@users.noreply.github.com>
Co-authored-by: Lauris BH <lauris@nix.lv>
tags/v1.10.5
@@ -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 |
@@ -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) | |||
} |
@@ -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) | |||
} |
@@ -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}) |
@@ -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) |
@@ -74,4 +74,16 @@ | |||
id: 13 | |||
user_id: 20 | |||
repo_id: 28 | |||
mode: 4 # owner | |||
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 |
@@ -14,4 +14,10 @@ | |||
id: 3 | |||
repo_id: 40 | |||
user_id: 4 | |||
mode: 2 # write | |||
mode: 2 # write | |||
- | |||
id: 4 | |||
repo_id: 4 | |||
user_id: 29 | |||
mode: 2 # write |
@@ -58,3 +58,8 @@ | |||
org_id: 6 | |||
is_public: true | |||
- | |||
id: 11 | |||
uid: 29 | |||
org_id: 17 | |||
is_public: true |
@@ -77,7 +77,7 @@ | |||
name: review_team | |||
authorize: 1 # read | |||
num_repos: 1 | |||
num_members: 1 | |||
num_members: 2 | |||
- | |||
id: 10 |
@@ -81,3 +81,9 @@ | |||
org_id: 6 | |||
team_id: 13 | |||
uid: 28 | |||
- | |||
id: 15 | |||
org_id: 17 | |||
team_id: 9 | |||
uid: 29 |
@@ -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 |
@@ -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 { |
@@ -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 |
@@ -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)) | |||
} |
@@ -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)) |
@@ -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) | |||
} |
@@ -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 | |||
} |
@@ -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) | |||
} | |||
@@ -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) | |||
@@ -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}) |
@@ -37,6 +37,7 @@ type AdminEditUserForm struct { | |||
MaxRepoCreation int | |||
Active bool | |||
Admin bool | |||
Restricted bool | |||
AllowGitHook bool | |||
AllowImportLocal bool | |||
AllowCreateOrganization bool |
@@ -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 |
@@ -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 |
@@ -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++ { |
@@ -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"), | |||
} |
@@ -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 |
@@ -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, |
@@ -144,6 +144,7 @@ func Dashboard(ctx *context.Context) { | |||
retrieveFeeds(ctx, models.GetFeedsOptions{ | |||
RequestedUser: ctxUser, | |||
Actor: ctx.User, | |||
IncludePrivate: true, | |||
OnlyPerformedBy: false, | |||
IncludeDeleted: false, |
@@ -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, |
@@ -83,6 +83,12 @@ | |||
<input name="admin" type="checkbox" {{if .User.IsAdmin}}checked{{end}}> | |||
</div> | |||
</div> | |||
<div class="inline field"> | |||
<div class="ui checkbox"> | |||
<label><strong>{{.i18n.Tr "admin.users.is_restricted"}}</strong></label> | |||
<input name="restricted" type="checkbox" {{if .User.IsRestricted}}checked{{end}}> | |||
</div> | |||
</div> | |||
<div class="inline field"> | |||
<div class="ui checkbox"> | |||
<label><strong>{{.i18n.Tr "admin.users.allow_git_hook"}}</strong></label> |
@@ -21,6 +21,7 @@ | |||
<th>{{.i18n.Tr "email"}}</th> | |||
<th>{{.i18n.Tr "admin.users.activated"}}</th> | |||
<th>{{.i18n.Tr "admin.users.admin"}}</th> | |||
<th>{{.i18n.Tr "admin.users.restricted"}}</th> | |||
<th>{{.i18n.Tr "admin.users.repos"}}</th> | |||
<th>{{.i18n.Tr "admin.users.created"}}</th> | |||
<th>{{.i18n.Tr "admin.users.last_login"}}</th> | |||
@@ -35,6 +36,7 @@ | |||
<td><span class="text truncate email">{{.Email}}</span></td> | |||
<td><i class="fa fa{{if .IsActive}}-check{{end}}-square-o"></i></td> | |||
<td><i class="fa fa{{if .IsAdmin}}-check{{end}}-square-o"></i></td> | |||
<td><i class="fa fa{{if .IsRestricted}}-check{{end}}-square-o"></i></td> | |||
<td>{{.NumRepos}}</td> | |||
<td><span title="{{.CreatedUnix.FormatLong}}">{{.CreatedUnix.FormatShort}}</span></td> | |||
{{if .LastLoginUnix}} |