From f6bec8529697bdb89ebcd0901ba093f06aa9ac46 Mon Sep 17 00:00:00 2001
From: Norwin <noerw@users.noreply.github.com>
Date: Tue, 22 Dec 2020 02:53:37 +0000
Subject: [PATCH] rework heatmap permissions (#14080)

* now uses the same permission model as for the activity feed:
  only include activities in repos, that the doer has access to.
  this might be somewhat slower.

* also improves handling of user.KeepActivityPrivate (still shows
  the heatmap to self & admins)

* extend tests

* adjust integration test to new behaviour

* add access to actions for admins

* extend heatmap unit tests
---
 integrations/privateactivity_test.go |  6 +-
 models/action.go                     | 96 +++++++++++++++++-----------
 models/fixtures/action.yml           |  9 +++
 models/user_heatmap.go               | 36 ++++++-----
 models/user_heatmap_test.go          | 29 ++++++---
 routers/api/v1/user/user.go          |  2 +-
 routers/user/home.go                 |  2 +-
 routers/user/profile.go              |  2 +-
 8 files changed, 113 insertions(+), 69 deletions(-)

diff --git a/integrations/privateactivity_test.go b/integrations/privateactivity_test.go
index bfdc2ef53..381cb6b33 100644
--- a/integrations/privateactivity_test.go
+++ b/integrations/privateactivity_test.go
@@ -388,7 +388,7 @@ func TestPrivateActivityYesHeatmapHasNoContentForUserItself(t *testing.T) {
 	session := loginUser(t, privateActivityTestUser)
 	hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session)
 
-	assert.False(t, hasContent, "user should have no heatmap content")
+	assert.True(t, hasContent, "user should see their own heatmap content")
 }
 
 func TestPrivateActivityYesHeatmapHasNoContentForOtherUser(t *testing.T) {
@@ -399,7 +399,7 @@ func TestPrivateActivityYesHeatmapHasNoContentForOtherUser(t *testing.T) {
 	session := loginUser(t, privateActivityTestOtherUser)
 	hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session)
 
-	assert.False(t, hasContent, "user should have no heatmap content")
+	assert.False(t, hasContent, "other user should not see heatmap content")
 }
 
 func TestPrivateActivityYesHeatmapHasNoContentForAdmin(t *testing.T) {
@@ -410,5 +410,5 @@ func TestPrivateActivityYesHeatmapHasNoContentForAdmin(t *testing.T) {
 	session := loginUser(t, privateActivityTestAdmin)
 	hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session)
 
-	assert.False(t, hasContent, "user should have no heatmap content")
+	assert.True(t, hasContent, "heatmap should show content for admin")
 }
diff --git a/models/action.go b/models/action.go
index 554640924..c39fdc397 100644
--- a/models/action.go
+++ b/models/action.go
@@ -298,46 +298,13 @@ type GetFeedsOptions struct {
 
 // GetFeeds returns actions according to the provided options
 func GetFeeds(opts GetFeedsOptions) ([]*Action, error) {
-	cond := builder.NewCond()
-
-	var repoIDs []int64
-	var actorID int64
-
-	if opts.Actor != nil {
-		actorID = opts.Actor.ID
+	if !activityReadable(opts.RequestedUser, opts.Actor) {
+		return make([]*Action, 0), nil
 	}
 
-	if opts.RequestedUser.IsOrganization() {
-		env, err := opts.RequestedUser.AccessibleReposEnv(actorID)
-		if err != nil {
-			return nil, fmt.Errorf("AccessibleReposEnv: %v", err)
-		}
-		if repoIDs, err = env.RepoIDs(1, opts.RequestedUser.NumRepos); err != nil {
-			return nil, fmt.Errorf("GetUserRepositories: %v", err)
-		}
-
-		cond = cond.And(builder.In("repo_id", repoIDs))
-	} else {
-		cond = cond.And(builder.In("repo_id", AccessibleRepoIDsQuery(opts.Actor)))
-	}
-
-	if opts.Actor == nil || !opts.Actor.IsAdmin {
-		if opts.RequestedUser.KeepActivityPrivate && actorID != opts.RequestedUser.ID {
-			return make([]*Action, 0), nil
-		}
-	}
-
-	cond = cond.And(builder.Eq{"user_id": opts.RequestedUser.ID})
-
-	if opts.OnlyPerformedBy {
-		cond = cond.And(builder.Eq{"act_user_id": opts.RequestedUser.ID})
-	}
-	if !opts.IncludePrivate {
-		cond = cond.And(builder.Eq{"is_private": false})
-	}
-
-	if !opts.IncludeDeleted {
-		cond = cond.And(builder.Eq{"is_deleted": false})
+	cond, err := activityQueryCondition(opts)
+	if err != nil {
+		return nil, err
 	}
 
 	actions := make([]*Action, 0, setting.UI.FeedPagingNum)
@@ -352,3 +319,56 @@ func GetFeeds(opts GetFeedsOptions) ([]*Action, error) {
 
 	return actions, nil
 }
+
+func activityReadable(user *User, doer *User) bool {
+	var doerID int64
+	if doer != nil {
+		doerID = doer.ID
+	}
+	if doer == nil || !doer.IsAdmin {
+		if user.KeepActivityPrivate && doerID != user.ID {
+			return false
+		}
+	}
+	return true
+}
+
+func activityQueryCondition(opts GetFeedsOptions) (builder.Cond, error) {
+	cond := builder.NewCond()
+
+	var repoIDs []int64
+	var actorID int64
+	if opts.Actor != nil {
+		actorID = opts.Actor.ID
+	}
+
+	// check readable repositories by doer/actor
+	if opts.Actor == nil || !opts.Actor.IsAdmin {
+		if opts.RequestedUser.IsOrganization() {
+			env, err := opts.RequestedUser.AccessibleReposEnv(actorID)
+			if err != nil {
+				return nil, fmt.Errorf("AccessibleReposEnv: %v", err)
+			}
+			if repoIDs, err = env.RepoIDs(1, opts.RequestedUser.NumRepos); err != nil {
+				return nil, fmt.Errorf("GetUserRepositories: %v", err)
+			}
+			cond = cond.And(builder.In("repo_id", repoIDs))
+		} else {
+			cond = cond.And(builder.In("repo_id", AccessibleRepoIDsQuery(opts.Actor)))
+		}
+	}
+
+	cond = cond.And(builder.Eq{"user_id": opts.RequestedUser.ID})
+
+	if opts.OnlyPerformedBy {
+		cond = cond.And(builder.Eq{"act_user_id": opts.RequestedUser.ID})
+	}
+	if !opts.IncludePrivate {
+		cond = cond.And(builder.Eq{"is_private": false})
+	}
+	if !opts.IncludeDeleted {
+		cond = cond.And(builder.Eq{"is_deleted": false})
+	}
+
+	return cond, nil
+}
diff --git a/models/fixtures/action.yml b/models/fixtures/action.yml
index eb92aeedb..14cfd9042 100644
--- a/models/fixtures/action.yml
+++ b/models/fixtures/action.yml
@@ -23,3 +23,12 @@
   act_user_id: 11
   repo_id: 9
   is_private: false
+
+-
+  id: 4
+  user_id: 16
+  op_type: 12 # close issue
+  act_user_id: 16
+  repo_id: 22
+  is_private: true
+  created_unix: 1603267920
diff --git a/models/user_heatmap.go b/models/user_heatmap.go
index ce3ec029c..425817e6d 100644
--- a/models/user_heatmap.go
+++ b/models/user_heatmap.go
@@ -16,10 +16,10 @@ type UserHeatmapData struct {
 }
 
 // GetUserHeatmapDataByUser returns an array of UserHeatmapData
-func GetUserHeatmapDataByUser(user *User) ([]*UserHeatmapData, error) {
+func GetUserHeatmapDataByUser(user *User, doer *User) ([]*UserHeatmapData, error) {
 	hdata := make([]*UserHeatmapData, 0)
 
-	if user.KeepActivityPrivate {
+	if !activityReadable(user, doer) {
 		return hdata, nil
 	}
 
@@ -37,22 +37,26 @@ func GetUserHeatmapDataByUser(user *User) ([]*UserHeatmapData, error) {
 		groupByName = groupBy
 	}
 
-	sess := x.Select(groupBy+" AS timestamp, count(user_id) as contributions").
-		Table("action").
-		Where("user_id = ?", user.ID).
-		And("created_unix > ?", (timeutil.TimeStampNow() - 31536000))
-
-	// * Heatmaps for individual users only include actions that the user themself
-	//   did.
-	// * For organizations actions by all users that were made in owned
-	//   repositories are counted.
-	if user.Type == UserTypeIndividual {
-		sess = sess.And("act_user_id = ?", user.ID)
+	cond, err := activityQueryCondition(GetFeedsOptions{
+		RequestedUser:  user,
+		Actor:          doer,
+		IncludePrivate: true, // don't filter by private, as we already filter by repo access
+		IncludeDeleted: true,
+		// * Heatmaps for individual users only include actions that the user themself did.
+		// * For organizations actions by all users that were made in owned
+		//   repositories are counted.
+		OnlyPerformedBy: !user.IsOrganization(),
+	})
+	if err != nil {
+		return nil, err
 	}
 
-	err := sess.GroupBy(groupByName).
+	return hdata, x.
+		Select(groupBy+" AS timestamp, count(user_id) as contributions").
+		Table("action").
+		Where(cond).
+		And("created_unix > ?", (timeutil.TimeStampNow() - 31536000)).
+		GroupBy(groupByName).
 		OrderBy("timestamp").
 		Find(&hdata)
-
-	return hdata, err
 }
diff --git a/models/user_heatmap_test.go b/models/user_heatmap_test.go
index c9d33db29..d98c4c63e 100644
--- a/models/user_heatmap_test.go
+++ b/models/user_heatmap_test.go
@@ -6,6 +6,7 @@ package models
 
 import (
 	"encoding/json"
+	"fmt"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -14,35 +15,45 @@ import (
 func TestGetUserHeatmapDataByUser(t *testing.T) {
 	testCases := []struct {
 		userID      int64
+		doerID      int64
 		CountResult int
 		JSONResult  string
 	}{
-		{2, 1, `[{"timestamp":1603152000,"contributions":1}]`},
-		{3, 0, `[]`},
+		{2, 2, 1, `[{"timestamp":1603152000,"contributions":1}]`}, // self looks at action in private repo
+		{2, 1, 1, `[{"timestamp":1603152000,"contributions":1}]`}, // admin looks at action in private repo
+		{2, 3, 0, `[]`}, // other user looks at action in private repo
+		{2, 0, 0, `[]`}, // nobody looks at action in private repo
+		{16, 15, 1, `[{"timestamp":1603238400,"contributions":1}]`}, // collaborator looks at action in private repo
+		{3, 3, 0, `[]`}, // no action action not performed by target user
 	}
 	// Prepare
 	assert.NoError(t, PrepareTestDatabase())
 
-	for _, tc := range testCases {
-
-		// Insert some action
+	for i, tc := range testCases {
 		user := AssertExistsAndLoadBean(t, &User{ID: tc.userID}).(*User)
 
+		doer := &User{ID: tc.doerID}
+		_, err := loadBeanIfExists(doer)
+		assert.NoError(t, err)
+		if tc.doerID == 0 {
+			doer = nil
+		}
+
 		// get the action for comparison
 		actions, err := GetFeeds(GetFeedsOptions{
 			RequestedUser:   user,
-			Actor:           user,
+			Actor:           doer,
 			IncludePrivate:  true,
-			OnlyPerformedBy: false,
+			OnlyPerformedBy: true,
 			IncludeDeleted:  true,
 		})
 		assert.NoError(t, err)
 
 		// Get the heatmap and compare
-		heatmap, err := GetUserHeatmapDataByUser(user)
+		heatmap, err := GetUserHeatmapDataByUser(user, doer)
 		assert.NoError(t, err)
 		assert.Equal(t, len(actions), len(heatmap), "invalid action count: did the test data became too old?")
-		assert.Equal(t, tc.CountResult, len(heatmap))
+		assert.Equal(t, tc.CountResult, len(heatmap), fmt.Sprintf("testcase %d", i))
 
 		//Test JSON rendering
 		jsonData, err := json.Marshal(heatmap)
diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go
index b552c1353..07d5e9112 100644
--- a/routers/api/v1/user/user.go
+++ b/routers/api/v1/user/user.go
@@ -166,7 +166,7 @@ func GetUserHeatmapData(ctx *context.APIContext) {
 		return
 	}
 
-	heatmap, err := models.GetUserHeatmapDataByUser(user)
+	heatmap, err := models.GetUserHeatmapDataByUser(user, ctx.User)
 	if err != nil {
 		ctx.Error(http.StatusInternalServerError, "GetUserHeatmapDataByUser", err)
 		return
diff --git a/routers/user/home.go b/routers/user/home.go
index 46532f82b..92a913847 100644
--- a/routers/user/home.go
+++ b/routers/user/home.go
@@ -115,7 +115,7 @@ func Dashboard(ctx *context.Context) {
 	// no heatmap access for admins; GetUserHeatmapDataByUser ignores the calling user
 	// so everyone would get the same empty heatmap
 	if setting.Service.EnableUserHeatmap && !ctxUser.KeepActivityPrivate {
-		data, err := models.GetUserHeatmapDataByUser(ctxUser)
+		data, err := models.GetUserHeatmapDataByUser(ctxUser, ctx.User)
 		if err != nil {
 			ctx.ServerError("GetUserHeatmapDataByUser", err)
 			return
diff --git a/routers/user/profile.go b/routers/user/profile.go
index 36f3d0735..bd5b35927 100644
--- a/routers/user/profile.go
+++ b/routers/user/profile.go
@@ -98,7 +98,7 @@ func Profile(ctx *context.Context) {
 	// no heatmap access for admins; GetUserHeatmapDataByUser ignores the calling user
 	// so everyone would get the same empty heatmap
 	if setting.Service.EnableUserHeatmap && !ctxUser.KeepActivityPrivate {
-		data, err := models.GetUserHeatmapDataByUser(ctxUser)
+		data, err := models.GetUserHeatmapDataByUser(ctxUser, ctx.User)
 		if err != nil {
 			ctx.ServerError("GetUserHeatmapDataByUser", err)
 			return