From ad6d08d155c67d6d3833d2961ed0fd5a2ba1ff88 Mon Sep 17 00:00:00 2001
From: Florin Hillebrand <flozzone@gmail.com>
Date: Fri, 29 Apr 2022 14:24:38 +0200
Subject: [PATCH] Add API to query collaborators permission for a repository
 (#18761)

Targeting #14936, #15332

Adds a collaborator permissions API endpoint according to GitHub API: https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user to retrieve a collaborators permissions for a specific repository.

### Checks the repository permissions of a collaborator.

`GET` `/repos/{owner}/{repo}/collaborators/{collaborator}/permission`

Possible `permission` values are `admin`, `write`, `read`, `owner`, `none`.

```json
{
  "permission": "admin",
  "role_name": "admin",
  "user": {}
}
```

Where `permission` and `role_name` hold the same `permission` value and `user` is filled with the user API object. Only admins are allowed to use this API endpoint.
---
 integrations/api_repo_collaborator_test.go | 131 +++++++++++++++++++++
 models/fixtures/user.yml                   |  32 +++++
 modules/convert/user.go                    |   9 ++
 modules/structs/repo_collaborator.go       |   7 ++
 routers/api/v1/api.go                      |   9 +-
 routers/api/v1/repo/collaborators.go       |  55 +++++++++
 routers/api/v1/swagger/repo.go             |   7 ++
 templates/swagger/v1_json.tmpl             |  70 +++++++++++
 8 files changed, 317 insertions(+), 3 deletions(-)
 create mode 100644 integrations/api_repo_collaborator_test.go

diff --git a/integrations/api_repo_collaborator_test.go b/integrations/api_repo_collaborator_test.go
new file mode 100644
index 0000000000..fdca1d9150
--- /dev/null
+++ b/integrations/api_repo_collaborator_test.go
@@ -0,0 +1,131 @@
+// Copyright 2022 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 integrations
+
+import (
+	"net/http"
+	"net/url"
+	"testing"
+
+	"code.gitea.io/gitea/models/perm"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	api "code.gitea.io/gitea/modules/structs"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAPIRepoCollaboratorPermission(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}).(*repo_model.Repository)
+		repo2Owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo2.OwnerID}).(*user_model.User)
+
+		user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}).(*user_model.User)
+		user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}).(*user_model.User)
+		user10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10}).(*user_model.User)
+		user11 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 11}).(*user_model.User)
+
+		session := loginUser(t, repo2Owner.Name)
+		testCtx := NewAPITestContext(t, repo2Owner.Name, repo2.Name)
+
+		t.Run("RepoOwnerShouldBeOwner", func(t *testing.T) {
+			req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission?token=%s", repo2Owner.Name, repo2.Name, repo2Owner.Name, testCtx.Token)
+			resp := session.MakeRequest(t, req, http.StatusOK)
+
+			var repoPermission api.RepoCollaboratorPermission
+			DecodeJSON(t, resp, &repoPermission)
+
+			assert.Equal(t, "owner", repoPermission.Permission)
+		})
+
+		t.Run("CollaboratorWithReadAccess", func(t *testing.T) {
+			t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user4.Name, perm.AccessModeRead))
+
+			req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission?token=%s", repo2Owner.Name, repo2.Name, user4.Name, testCtx.Token)
+			resp := session.MakeRequest(t, req, http.StatusOK)
+
+			var repoPermission api.RepoCollaboratorPermission
+			DecodeJSON(t, resp, &repoPermission)
+
+			assert.Equal(t, "read", repoPermission.Permission)
+		})
+
+		t.Run("CollaboratorWithWriteAccess", func(t *testing.T) {
+			t.Run("AddUserAsCollaboratorWithWriteAccess", doAPIAddCollaborator(testCtx, user4.Name, perm.AccessModeWrite))
+
+			req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission?token=%s", repo2Owner.Name, repo2.Name, user4.Name, testCtx.Token)
+			resp := session.MakeRequest(t, req, http.StatusOK)
+
+			var repoPermission api.RepoCollaboratorPermission
+			DecodeJSON(t, resp, &repoPermission)
+
+			assert.Equal(t, "write", repoPermission.Permission)
+		})
+
+		t.Run("CollaboratorWithAdminAccess", func(t *testing.T) {
+			t.Run("AddUserAsCollaboratorWithAdminAccess", doAPIAddCollaborator(testCtx, user4.Name, perm.AccessModeAdmin))
+
+			req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission?token=%s", repo2Owner.Name, repo2.Name, user4.Name, testCtx.Token)
+			resp := session.MakeRequest(t, req, http.StatusOK)
+
+			var repoPermission api.RepoCollaboratorPermission
+			DecodeJSON(t, resp, &repoPermission)
+
+			assert.Equal(t, "admin", repoPermission.Permission)
+		})
+
+		t.Run("CollaboratorNotFound", func(t *testing.T) {
+			req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission?token=%s", repo2Owner.Name, repo2.Name, "non-existent-user", testCtx.Token)
+			session.MakeRequest(t, req, http.StatusNotFound)
+		})
+
+		t.Run("CollaboratorCanQueryItsPermissions", func(t *testing.T) {
+			t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user5.Name, perm.AccessModeRead))
+
+			_session := loginUser(t, user5.Name)
+			_testCtx := NewAPITestContext(t, user5.Name, repo2.Name)
+
+			req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission?token=%s", repo2Owner.Name, repo2.Name, user5.Name, _testCtx.Token)
+			resp := _session.MakeRequest(t, req, http.StatusOK)
+
+			var repoPermission api.RepoCollaboratorPermission
+			DecodeJSON(t, resp, &repoPermission)
+
+			assert.Equal(t, "read", repoPermission.Permission)
+		})
+
+		t.Run("CollaboratorCanQueryItsPermissions", func(t *testing.T) {
+			t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user5.Name, perm.AccessModeRead))
+
+			_session := loginUser(t, user5.Name)
+			_testCtx := NewAPITestContext(t, user5.Name, repo2.Name)
+
+			req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission?token=%s", repo2Owner.Name, repo2.Name, user5.Name, _testCtx.Token)
+			resp := _session.MakeRequest(t, req, http.StatusOK)
+
+			var repoPermission api.RepoCollaboratorPermission
+			DecodeJSON(t, resp, &repoPermission)
+
+			assert.Equal(t, "read", repoPermission.Permission)
+		})
+
+		t.Run("RepoAdminCanQueryACollaboratorsPermissions", func(t *testing.T) {
+			t.Run("AddUserAsCollaboratorWithAdminAccess", doAPIAddCollaborator(testCtx, user10.Name, perm.AccessModeAdmin))
+			t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user11.Name, perm.AccessModeRead))
+
+			_session := loginUser(t, user10.Name)
+			_testCtx := NewAPITestContext(t, user10.Name, repo2.Name)
+
+			req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission?token=%s", repo2Owner.Name, repo2.Name, user11.Name, _testCtx.Token)
+			resp := _session.MakeRequest(t, req, http.StatusOK)
+
+			var repoPermission api.RepoCollaboratorPermission
+			DecodeJSON(t, resp, &repoPermission)
+
+			assert.Equal(t, "read", repoPermission.Permission)
+		})
+	})
+}
diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml
index 670b305621..67ba869c76 100644
--- a/models/fixtures/user.yml
+++ b/models/fixtures/user.yml
@@ -4,6 +4,7 @@
   id: 1
   lower_name: user1
   name: user1
+  login_name: user1
   full_name: User One
   email: user1@example.com
   email_notifications_preference: enabled
@@ -21,6 +22,7 @@
   id: 2
   lower_name: user2
   name: user2
+  login_name: user2
   full_name: "   < U<se>r Tw<o > ><  "
   email: user2@example.com
   keep_email_private: true
@@ -42,6 +44,7 @@
   id: 3
   lower_name: user3
   name: user3
+  login_name: user3
   full_name: " <<<< >> >> > >> > >>> >> "
   email: user3@example.com
   email_notifications_preference: onmention
@@ -60,6 +63,7 @@
   id: 4
   lower_name: user4
   name: user4
+  login_name: user4
   full_name: "          "
   email: user4@example.com
   email_notifications_preference: onmention
@@ -78,6 +82,7 @@
   id: 5
   lower_name: user5
   name: user5
+  login_name: user5
   full_name: User Five
   email: user5@example.com
   email_notifications_preference: enabled
@@ -97,6 +102,7 @@
   id: 6
   lower_name: user6
   name: user6
+  login_name: user6
   full_name: User Six
   email: user6@example.com
   email_notifications_preference: enabled
@@ -115,6 +121,7 @@
   id: 7
   lower_name: user7
   name: user7
+  login_name: user7
   full_name: User Seven
   email: user7@example.com
   email_notifications_preference: disabled
@@ -133,6 +140,7 @@
   id: 8
   lower_name: user8
   name: user8
+  login_name: user8
   full_name: User Eight
   email: user8@example.com
   email_notifications_preference: enabled
@@ -152,6 +160,7 @@
   id: 9
   lower_name: user9
   name: user9
+  login_name: user9
   full_name: User Nine
   email: user9@example.com
   email_notifications_preference: onmention
@@ -169,6 +178,7 @@
   id: 10
   lower_name: user10
   name: user10
+  login_name: user10
   full_name: User Ten
   email: user10@example.com
   passwd_hash_algo: argon2
@@ -185,6 +195,7 @@
   id: 11
   lower_name: user11
   name: user11
+  login_name: user11
   full_name: User Eleven
   email: user11@example.com
   passwd_hash_algo: argon2
@@ -201,6 +212,7 @@
   id: 12
   lower_name: user12
   name: user12
+  login_name: user12
   full_name: User 12
   email: user12@example.com
   passwd_hash_algo: argon2
@@ -217,6 +229,7 @@
   id: 13
   lower_name: user13
   name: user13
+  login_name: user13
   full_name: User 13
   email: user13@example.com
   passwd_hash_algo: argon2
@@ -233,6 +246,7 @@
   id: 14
   lower_name: user14
   name: user14
+  login_name: user14
   full_name: User 14
   email: user14@example.com
   passwd_hash_algo: argon2
@@ -249,6 +263,7 @@
   id: 15
   lower_name: user15
   name: user15
+  login_name: user15
   full_name: User 15
   email: user15@example.com
   passwd_hash_algo: argon2
@@ -265,6 +280,7 @@
   id: 16
   lower_name: user16
   name: user16
+  login_name: user16
   full_name: User 16
   email: user16@example.com
   passwd_hash_algo: argon2
@@ -281,6 +297,7 @@
   id: 17
   lower_name: user17
   name: user17
+  login_name: user17
   full_name: User 17
   email: user17@example.com
   passwd_hash_algo: argon2
@@ -299,6 +316,7 @@
   id: 18
   lower_name: user18
   name: user18
+  login_name: user18
   full_name: User 18
   email: user18@example.com
   passwd_hash_algo: argon2
@@ -315,6 +333,7 @@
   id: 19
   lower_name: user19
   name: user19
+  login_name: user19
   full_name: User 19
   email: user19@example.com
   passwd_hash_algo: argon2
@@ -333,6 +352,7 @@
   id: 20
   lower_name: user20
   name: user20
+  login_name: user20
   full_name: User 20
   email: user20@example.com
   passwd_hash_algo: argon2
@@ -349,6 +369,7 @@
   id: 21
   lower_name: user21
   name: user21
+  login_name: user21
   full_name: User 21
   email: user21@example.com
   passwd_hash_algo: argon2
@@ -365,6 +386,7 @@
   id: 22
   lower_name: limited_org
   name: limited_org
+  login_name: limited_org
   full_name: Limited Org
   email: limited_org@example.com
   passwd_hash_algo: argon2
@@ -384,6 +406,7 @@
   id: 23
   lower_name: privated_org
   name: privated_org
+  login_name: privated_org
   full_name: Privated Org
   email: privated_org@example.com
   passwd_hash_algo: argon2
@@ -403,6 +426,7 @@
   id: 24
   lower_name: user24
   name: user24
+  login_name: user24
   full_name: "user24"
   email: user24@example.com
   keep_email_private: true
@@ -423,6 +447,7 @@
   id: 25
   lower_name: org25
   name: org25
+  login_name: org25
   full_name: "org25"
   email: org25@example.com
   passwd_hash_algo: argon2
@@ -440,6 +465,7 @@
   id: 26
   lower_name: org26
   name: org26
+  login_name: org26
   full_name: "Org26"
   email: org26@example.com
   email_notifications_preference: onmention
@@ -459,6 +485,7 @@
   id: 27
   lower_name: user27
   name: user27
+  login_name: user27
   full_name: User Twenty-Seven
   email: user27@example.com
   email_notifications_preference: enabled
@@ -475,6 +502,7 @@
   id: 28
   lower_name: user28
   name: user28
+  login_name: user28
   full_name: "user27"
   email: user28@example.com
   keep_email_private: true
@@ -495,6 +523,7 @@
   id: 29
   lower_name: user29
   name: user29
+  login_name: user29
   full_name: User 29
   email: user29@example.com
   passwd_hash_algo: argon2
@@ -512,6 +541,7 @@
   id: 30
   lower_name: user30
   name: user30
+  login_name: user30
   full_name: User Thirty
   email: user30@example.com
   passwd_hash_algo: argon2
@@ -530,6 +560,7 @@
   id: 31
   lower_name: user31
   name: user31
+  login_name: user31
   full_name: "user31"
   email: user31@example.com
   passwd_hash_algo: argon2
@@ -547,6 +578,7 @@
   id: 32
   lower_name: user32
   name: user32
+  login_name: user32
   full_name: User 32 (U2F test)
   email: user32@example.com
   passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
diff --git a/modules/convert/user.go b/modules/convert/user.go
index dc4a8c49c7..2b07d21838 100644
--- a/modules/convert/user.go
+++ b/modules/convert/user.go
@@ -95,3 +95,12 @@ func User2UserSettings(user *user_model.User) api.UserSettings {
 		DiffViewStyle: user.DiffViewStyle,
 	}
 }
+
+// ToUserAndPermission return User and its collaboration permission for a repository
+func ToUserAndPermission(user, doer *user_model.User, accessMode perm.AccessMode) api.RepoCollaboratorPermission {
+	return api.RepoCollaboratorPermission{
+		User:       ToUser(user, doer),
+		Permission: accessMode.String(),
+		RoleName:   accessMode.String(),
+	}
+}
diff --git a/modules/structs/repo_collaborator.go b/modules/structs/repo_collaborator.go
index 2b4fa390d2..2f9c8992a1 100644
--- a/modules/structs/repo_collaborator.go
+++ b/modules/structs/repo_collaborator.go
@@ -8,3 +8,10 @@ package structs
 type AddCollaboratorOption struct {
 	Permission *string `json:"permission"`
 }
+
+// RepoCollaboratorPermission to get repository permission for a collaborator
+type RepoCollaboratorPermission struct {
+	Permission string `json:"permission"`
+	RoleName   string `json:"role_name"`
+	User       *User  `json:"user"`
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 782500e6c8..9351cc1510 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -810,9 +810,12 @@ func Routes() *web.Route {
 				}, reqToken(), reqAdmin(), reqWebhooksEnabled())
 				m.Group("/collaborators", func() {
 					m.Get("", reqAnyRepoReader(), repo.ListCollaborators)
-					m.Combo("/{collaborator}").Get(reqAnyRepoReader(), repo.IsCollaborator).
-						Put(reqAdmin(), bind(api.AddCollaboratorOption{}), repo.AddCollaborator).
-						Delete(reqAdmin(), repo.DeleteCollaborator)
+					m.Group("/{collaborator}", func() {
+						m.Combo("").Get(reqAnyRepoReader(), repo.IsCollaborator).
+							Put(reqAdmin(), bind(api.AddCollaboratorOption{}), repo.AddCollaborator).
+							Delete(reqAdmin(), repo.DeleteCollaborator)
+						m.Get("/permission", repo.GetRepoPermissions)
+					}, reqToken())
 				}, reqToken())
 				m.Get("/assignees", reqToken(), reqAnyRepoReader(), repo.GetAssignees)
 				m.Get("/reviewers", reqToken(), reqAnyRepoReader(), repo.GetReviewers)
diff --git a/routers/api/v1/repo/collaborators.go b/routers/api/v1/repo/collaborators.go
index 3bb6113d77..2db1724b2a 100644
--- a/routers/api/v1/repo/collaborators.go
+++ b/routers/api/v1/repo/collaborators.go
@@ -233,6 +233,61 @@ func DeleteCollaborator(ctx *context.APIContext) {
 	ctx.Status(http.StatusNoContent)
 }
 
+// GetRepoPermissions gets repository permissions for a user
+func GetRepoPermissions(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/collaborators/{collaborator}/permission repository repoGetRepoPermissions
+	// ---
+	// summary: Get repository permissions for a user
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: owner
+	//   in: path
+	//   description: owner of the repo
+	//   type: string
+	//   required: true
+	// - name: repo
+	//   in: path
+	//   description: name of the repo
+	//   type: string
+	//   required: true
+	// - name: collaborator
+	//   in: path
+	//   description: username of the collaborator
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/RepoCollaboratorPermission"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+
+	if !ctx.Doer.IsAdmin && ctx.Doer.LoginName != ctx.Params(":collaborator") && !ctx.IsUserRepoAdmin() {
+		ctx.Error(http.StatusForbidden, "User", "Only admins can query all permissions, repo admins can query all repo permissions, collaborators can query only their own")
+		return
+	}
+
+	collaborator, err := user_model.GetUserByName(ctx.Params(":collaborator"))
+	if err != nil {
+		if user_model.IsErrUserNotExist(err) {
+			ctx.Error(http.StatusNotFound, "GetUserByName", err)
+		} else {
+			ctx.Error(http.StatusInternalServerError, "GetUserByName", err)
+		}
+		return
+	}
+
+	permission, err := models.GetUserRepoPermission(ctx, ctx.Repo.Repository, collaborator)
+	if err != nil {
+		ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, convert.ToUserAndPermission(collaborator, ctx.ContextUser, permission.AccessMode))
+}
+
 // GetReviewers return all users that can be requested to review in this repo
 func GetReviewers(ctx *context.APIContext) {
 	// swagger:operation GET /repos/{owner}/{repo}/reviewers repository repoGetReviewers
diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go
index 40aeca677d..ab802db781 100644
--- a/routers/api/v1/swagger/repo.go
+++ b/routers/api/v1/swagger/repo.go
@@ -344,3 +344,10 @@ type swaggerWikiCommitList struct {
 	// in:body
 	Body api.WikiCommitList `json:"body"`
 }
+
+// RepoCollaboratorPermission
+// swagger:response RepoCollaboratorPermission
+type swaggerRepoCollaboratorPermission struct {
+	// in:body
+	Body api.RepoCollaboratorPermission `json:"body"`
+}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index d57a3a580b..3e4813f22c 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -3129,6 +3129,52 @@
         }
       }
     },
+    "/repos/{owner}/{repo}/collaborators/{collaborator}/permission": {
+      "get": {
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "repository"
+        ],
+        "summary": "Get repository permissions for a user",
+        "operationId": "repoGetRepoPermissions",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "owner of the repo",
+            "name": "owner",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "name of the repo",
+            "name": "repo",
+            "in": "path",
+            "required": true
+          },
+          {
+            "type": "string",
+            "description": "username of the collaborator",
+            "name": "collaborator",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/RepoCollaboratorPermission"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/repos/{owner}/{repo}/commits": {
       "get": {
         "produces": [
@@ -17451,6 +17497,24 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "RepoCollaboratorPermission": {
+      "description": "RepoCollaboratorPermission to get repository permission for a collaborator",
+      "type": "object",
+      "properties": {
+        "permission": {
+          "type": "string",
+          "x-go-name": "Permission"
+        },
+        "role_name": {
+          "type": "string",
+          "x-go-name": "RoleName"
+        },
+        "user": {
+          "$ref": "#/definitions/User"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "RepoCommit": {
       "type": "object",
       "title": "RepoCommit contains information of a commit in the context of a repository.",
@@ -19126,6 +19190,12 @@
         }
       }
     },
+    "RepoCollaboratorPermission": {
+      "description": "RepoCollaboratorPermission",
+      "schema": {
+        "$ref": "#/definitions/RepoCollaboratorPermission"
+      }
+    },
     "Repository": {
       "description": "Repository",
       "schema": {