Skip to content

Commit 1751d5f

Browse files
mnshsapklafriks
committed
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 <[email protected]> * Mention restricted users in the faq Signed-off-by: Manush Dodunekov <[email protected]> * Revert unnecessary change `.isUserPartOfOrg` -> `.IsUserPartOfOrg` Signed-off-by: Manush Dodunekov <[email protected]> * Remove unnecessary `org.IsOrganization()` call Signed-off-by: Manush Dodunekov <[email protected]> * 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 <[email protected]> * 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 5c3e886. Co-authored-by: Antoine GIRARD <[email protected]> Co-authored-by: Lauris BH <[email protected]>
1 parent 0b3aaa6 commit 1751d5f

31 files changed

+310
-124
lines changed

docs/content/doc/help/faq.en-us.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Also see [Support Options]({{< relref "doc/help/seek-help.en-us.md" >}})
3131
* [Only allow certain email domains](#only-allow-certain-email-domains)
3232
* [Only allow/block certain OpenID providers](#only-allow-block-certain-openid-providers)
3333
* [Issue only users](#issue-only-users)
34+
* [Restricted users](#restricted-users)
3435
* [Enable Fail2ban](#enable-fail2ban)
3536
* [Adding custom themes](#how-to-add-use-custom-themes)
3637
* [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
147148
### Issue only users
148149
The current way to achieve this is to create/modify a user with a max repo creation limit of 0.
149150

151+
### Restricted users
152+
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.__
153+
154+
Example use case: A company runs a Gitea instance that requires login. Most repos are public (accessible/browseable by all co-workers).
155+
156+
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.
157+
158+
150159
### Enable Fail2ban
151160

152161
Use [Fail2Ban]({{ relref "doc/usage/fail2ban-setup.md" >}}) to monitor and stop automated login attempts or other malicious behavior based on log patterns

models/access.go

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,17 @@ type Access struct {
7171
Mode AccessMode
7272
}
7373

74-
func accessLevel(e Engine, userID int64, repo *Repository) (AccessMode, error) {
74+
func accessLevel(e Engine, user *User, repo *Repository) (AccessMode, error) {
7575
mode := AccessModeNone
76-
if !repo.IsPrivate {
76+
var userID int64
77+
restricted := false
78+
79+
if user != nil {
80+
userID = user.ID
81+
restricted = user.IsRestricted
82+
}
83+
84+
if !restricted && !repo.IsPrivate {
7785
mode = AccessModeRead
7886
}
7987

@@ -162,22 +170,37 @@ func maxAccessMode(modes ...AccessMode) AccessMode {
162170
return max
163171
}
164172

173+
type userAccess struct {
174+
User *User
175+
Mode AccessMode
176+
}
177+
178+
// updateUserAccess updates an access map so that user has at least mode
179+
func updateUserAccess(accessMap map[int64]*userAccess, user *User, mode AccessMode) {
180+
if ua, ok := accessMap[user.ID]; ok {
181+
ua.Mode = maxAccessMode(ua.Mode, mode)
182+
} else {
183+
accessMap[user.ID] = &userAccess{User: user, Mode: mode}
184+
}
185+
}
186+
165187
// FIXME: do cross-comparison so reduce deletions and additions to the minimum?
166-
func (repo *Repository) refreshAccesses(e Engine, accessMap map[int64]AccessMode) (err error) {
188+
func (repo *Repository) refreshAccesses(e Engine, accessMap map[int64]*userAccess) (err error) {
167189
minMode := AccessModeRead
168190
if !repo.IsPrivate {
169191
minMode = AccessModeWrite
170192
}
171193

172194
newAccesses := make([]Access, 0, len(accessMap))
173-
for userID, mode := range accessMap {
174-
if mode < minMode {
195+
for userID, ua := range accessMap {
196+
if ua.Mode < minMode && !ua.User.IsRestricted {
175197
continue
176198
}
199+
177200
newAccesses = append(newAccesses, Access{
178201
UserID: userID,
179202
RepoID: repo.ID,
180-
Mode: mode,
203+
Mode: ua.Mode,
181204
})
182205
}
183206

@@ -191,13 +214,13 @@ func (repo *Repository) refreshAccesses(e Engine, accessMap map[int64]AccessMode
191214
}
192215

193216
// refreshCollaboratorAccesses retrieves repository collaborations with their access modes.
194-
func (repo *Repository) refreshCollaboratorAccesses(e Engine, accessMap map[int64]AccessMode) error {
195-
collaborations, err := repo.getCollaborations(e)
217+
func (repo *Repository) refreshCollaboratorAccesses(e Engine, accessMap map[int64]*userAccess) error {
218+
collaborators, err := repo.getCollaborators(e)
196219
if err != nil {
197220
return fmt.Errorf("getCollaborations: %v", err)
198221
}
199-
for _, c := range collaborations {
200-
accessMap[c.UserID] = c.Mode
222+
for _, c := range collaborators {
223+
updateUserAccess(accessMap, c.User, c.Collaboration.Mode)
201224
}
202225
return nil
203226
}
@@ -206,7 +229,7 @@ func (repo *Repository) refreshCollaboratorAccesses(e Engine, accessMap map[int6
206229
// except the team whose ID is given. It is used to assign a team ID when
207230
// remove repository from that team.
208231
func (repo *Repository) recalculateTeamAccesses(e Engine, ignTeamID int64) (err error) {
209-
accessMap := make(map[int64]AccessMode, 20)
232+
accessMap := make(map[int64]*userAccess, 20)
210233

211234
if err = repo.getOwner(e); err != nil {
212235
return err
@@ -239,7 +262,7 @@ func (repo *Repository) recalculateTeamAccesses(e Engine, ignTeamID int64) (err
239262
return fmt.Errorf("getMembers '%d': %v", t.ID, err)
240263
}
241264
for _, m := range t.Members {
242-
accessMap[m.ID] = maxAccessMode(accessMap[m.ID], t.Authorize)
265+
updateUserAccess(accessMap, m, t.Authorize)
243266
}
244267
}
245268

@@ -300,7 +323,7 @@ func (repo *Repository) recalculateAccesses(e Engine) error {
300323
return repo.recalculateTeamAccesses(e, 0)
301324
}
302325

303-
accessMap := make(map[int64]AccessMode, 20)
326+
accessMap := make(map[int64]*userAccess, 20)
304327
if err := repo.refreshCollaboratorAccesses(e, accessMap); err != nil {
305328
return fmt.Errorf("refreshCollaboratorAccesses: %v", err)
306329
}

models/access_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,20 @@ func TestAccessLevel(t *testing.T) {
1515

1616
user2 := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
1717
user5 := AssertExistsAndLoadBean(t, &User{ID: 5}).(*User)
18+
user29 := AssertExistsAndLoadBean(t, &User{ID: 29}).(*User)
1819
// A public repository owned by User 2
1920
repo1 := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
2021
assert.False(t, repo1.IsPrivate)
2122
// A private repository owned by Org 3
2223
repo3 := AssertExistsAndLoadBean(t, &Repository{ID: 3}).(*Repository)
2324
assert.True(t, repo3.IsPrivate)
2425

26+
// Another public repository
27+
repo4 := AssertExistsAndLoadBean(t, &Repository{ID: 4}).(*Repository)
28+
assert.False(t, repo4.IsPrivate)
29+
// org. owned private repo
30+
repo24 := AssertExistsAndLoadBean(t, &Repository{ID: 24}).(*Repository)
31+
2532
level, err := AccessLevel(user2, repo1)
2633
assert.NoError(t, err)
2734
assert.Equal(t, AccessModeOwner, level)
@@ -37,6 +44,21 @@ func TestAccessLevel(t *testing.T) {
3744
level, err = AccessLevel(user5, repo3)
3845
assert.NoError(t, err)
3946
assert.Equal(t, AccessModeNone, level)
47+
48+
// restricted user has no access to a public repo
49+
level, err = AccessLevel(user29, repo1)
50+
assert.NoError(t, err)
51+
assert.Equal(t, AccessModeNone, level)
52+
53+
// ... unless he's a collaborator
54+
level, err = AccessLevel(user29, repo4)
55+
assert.NoError(t, err)
56+
assert.Equal(t, AccessModeWrite, level)
57+
58+
// ... or a team member
59+
level, err = AccessLevel(user29, repo24)
60+
assert.NoError(t, err)
61+
assert.Equal(t, AccessModeRead, level)
4062
}
4163

4264
func TestHasAccess(t *testing.T) {
@@ -72,6 +94,11 @@ func TestUser_GetRepositoryAccesses(t *testing.T) {
7294
accesses, err := user1.GetRepositoryAccesses()
7395
assert.NoError(t, err)
7496
assert.Len(t, accesses, 0)
97+
98+
user29 := AssertExistsAndLoadBean(t, &User{ID: 29}).(*User)
99+
accesses, err = user29.GetRepositoryAccesses()
100+
assert.NoError(t, err)
101+
assert.Len(t, accesses, 2)
75102
}
76103

77104
func TestUser_GetAccessibleRepositories(t *testing.T) {
@@ -86,6 +113,11 @@ func TestUser_GetAccessibleRepositories(t *testing.T) {
86113
repos, err = user2.GetAccessibleRepositories(0)
87114
assert.NoError(t, err)
88115
assert.Len(t, repos, 1)
116+
117+
user29 := AssertExistsAndLoadBean(t, &User{ID: 29}).(*User)
118+
repos, err = user29.GetAccessibleRepositories(0)
119+
assert.NoError(t, err)
120+
assert.Len(t, repos, 2)
89121
}
90122

91123
func TestRepository_RecalculateAccesses(t *testing.T) {
@@ -119,3 +151,21 @@ func TestRepository_RecalculateAccesses2(t *testing.T) {
119151
assert.NoError(t, err)
120152
assert.False(t, has)
121153
}
154+
155+
func TestRepository_RecalculateAccesses3(t *testing.T) {
156+
assert.NoError(t, PrepareTestDatabase())
157+
team5 := AssertExistsAndLoadBean(t, &Team{ID: 5}).(*Team)
158+
user29 := AssertExistsAndLoadBean(t, &User{ID: 29}).(*User)
159+
160+
has, err := x.Get(&Access{UserID: 29, RepoID: 23})
161+
assert.NoError(t, err)
162+
assert.False(t, has)
163+
164+
// adding user29 to team5 should add an explicit access row for repo 23
165+
// even though repo 23 is public
166+
assert.NoError(t, AddTeamMember(team5, user29.ID))
167+
168+
has, err = x.Get(&Access{UserID: 29, RepoID: 23})
169+
assert.NoError(t, err)
170+
assert.True(t, has)
171+
}

models/action.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -284,20 +284,26 @@ func (a *Action) GetIssueContent() string {
284284

285285
// GetFeedsOptions options for retrieving feeds
286286
type GetFeedsOptions struct {
287-
RequestedUser *User
288-
RequestingUserID int64
289-
IncludePrivate bool // include private actions
290-
OnlyPerformedBy bool // only actions performed by requested user
291-
IncludeDeleted bool // include deleted actions
287+
RequestedUser *User // the user we want activity for
288+
Actor *User // the user viewing the activity
289+
IncludePrivate bool // include private actions
290+
OnlyPerformedBy bool // only actions performed by requested user
291+
IncludeDeleted bool // include deleted actions
292292
}
293293

294294
// GetFeeds returns actions according to the provided options
295295
func GetFeeds(opts GetFeedsOptions) ([]*Action, error) {
296296
cond := builder.NewCond()
297297

298298
var repoIDs []int64
299+
var actorID int64
300+
301+
if opts.Actor != nil {
302+
actorID = opts.Actor.ID
303+
}
304+
299305
if opts.RequestedUser.IsOrganization() {
300-
env, err := opts.RequestedUser.AccessibleReposEnv(opts.RequestingUserID)
306+
env, err := opts.RequestedUser.AccessibleReposEnv(actorID)
301307
if err != nil {
302308
return nil, fmt.Errorf("AccessibleReposEnv: %v", err)
303309
}
@@ -306,6 +312,8 @@ func GetFeeds(opts GetFeedsOptions) ([]*Action, error) {
306312
}
307313

308314
cond = cond.And(builder.In("repo_id", repoIDs))
315+
} else if opts.Actor != nil {
316+
cond = cond.And(builder.In("repo_id", opts.Actor.AccessibleRepoIDsQuery()))
309317
}
310318

311319
cond = cond.And(builder.Eq{"user_id": opts.RequestedUser.ID})

models/action_test.go

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@ func TestGetFeeds(t *testing.T) {
3333
user := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
3434

3535
actions, err := GetFeeds(GetFeedsOptions{
36-
RequestedUser: user,
37-
RequestingUserID: user.ID,
38-
IncludePrivate: true,
39-
OnlyPerformedBy: false,
40-
IncludeDeleted: true,
36+
RequestedUser: user,
37+
Actor: user,
38+
IncludePrivate: true,
39+
OnlyPerformedBy: false,
40+
IncludeDeleted: true,
4141
})
4242
assert.NoError(t, err)
4343
if assert.Len(t, actions, 1) {
@@ -46,10 +46,10 @@ func TestGetFeeds(t *testing.T) {
4646
}
4747

4848
actions, err = GetFeeds(GetFeedsOptions{
49-
RequestedUser: user,
50-
RequestingUserID: user.ID,
51-
IncludePrivate: false,
52-
OnlyPerformedBy: false,
49+
RequestedUser: user,
50+
Actor: user,
51+
IncludePrivate: false,
52+
OnlyPerformedBy: false,
5353
})
5454
assert.NoError(t, err)
5555
assert.Len(t, actions, 0)
@@ -59,14 +59,14 @@ func TestGetFeeds2(t *testing.T) {
5959
// test with an organization user
6060
assert.NoError(t, PrepareTestDatabase())
6161
org := AssertExistsAndLoadBean(t, &User{ID: 3}).(*User)
62-
const userID = 2 // user2 is an owner of the organization
62+
user := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
6363

6464
actions, err := GetFeeds(GetFeedsOptions{
65-
RequestedUser: org,
66-
RequestingUserID: userID,
67-
IncludePrivate: true,
68-
OnlyPerformedBy: false,
69-
IncludeDeleted: true,
65+
RequestedUser: org,
66+
Actor: user,
67+
IncludePrivate: true,
68+
OnlyPerformedBy: false,
69+
IncludeDeleted: true,
7070
})
7171
assert.NoError(t, err)
7272
assert.Len(t, actions, 1)
@@ -76,11 +76,11 @@ func TestGetFeeds2(t *testing.T) {
7676
}
7777

7878
actions, err = GetFeeds(GetFeedsOptions{
79-
RequestedUser: org,
80-
RequestingUserID: userID,
81-
IncludePrivate: false,
82-
OnlyPerformedBy: false,
83-
IncludeDeleted: true,
79+
RequestedUser: org,
80+
Actor: user,
81+
IncludePrivate: false,
82+
OnlyPerformedBy: false,
83+
IncludeDeleted: true,
8484
})
8585
assert.NoError(t, err)
8686
assert.Len(t, actions, 0)

models/fixtures/access.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,16 @@
7474
id: 13
7575
user_id: 20
7676
repo_id: 28
77-
mode: 4 # owner
77+
mode: 4 # owner
78+
79+
-
80+
id: 14
81+
user_id: 29
82+
repo_id: 4
83+
mode: 2 # write (collaborator)
84+
85+
-
86+
id: 15
87+
user_id: 29
88+
repo_id: 24
89+
mode: 1 # read

models/fixtures/collaboration.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,10 @@
1414
id: 3
1515
repo_id: 40
1616
user_id: 4
17-
mode: 2 # write
17+
mode: 2 # write
18+
19+
-
20+
id: 4
21+
repo_id: 4
22+
user_id: 29
23+
mode: 2 # write

models/fixtures/org_user.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,8 @@
5858
org_id: 6
5959
is_public: true
6060

61+
-
62+
id: 11
63+
uid: 29
64+
org_id: 17
65+
is_public: true

models/fixtures/team.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
name: review_team
7878
authorize: 1 # read
7979
num_repos: 1
80-
num_members: 1
80+
num_members: 2
8181

8282
-
8383
id: 10

models/fixtures/team_user.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,9 @@
8181
org_id: 6
8282
team_id: 13
8383
uid: 28
84+
85+
-
86+
id: 15
87+
org_id: 17
88+
team_id: 9
89+
uid: 29

0 commit comments

Comments
 (0)