From e78786ef39526fac6705a831fc291c55f1ca091b Mon Sep 17 00:00:00 2001
From: Vlad Temian <vladtemian@gmail.com>
Date: Sun, 7 Jan 2018 00:55:53 +0200
Subject: [PATCH] Writable deploy keys (closes #671) (#3225)

* Add is_writable checkbox to deploy keys interface

* Add writable key option to deploy key form

* Add support for writable ssh keys in the interface

* Rename IsWritable to ReadOnly

* Test: create read-only and read-write deploy keys via api

* Add DeployKey access mode migration

* Update gitea sdk via govendor

* Fix deploykey migration

* Add unittests for writable deploy keys

* Move template text to locale

* Remove implicit column update

* Remove duplicate locales

* Replace ReadOnly field with IsReadOnly method

* Fix deploy_keys related integration test

* Rename v54 migration with v55

* Fix migration hell
---
 integrations/api_keys_test.go              | 53 ++++++++++++++++++++++++++
 models/fixtures/deploy_key.yml             |  1 +
 models/migrations/migrations.go            |  2 +
 models/migrations/v55.go                   | 23 +++++++++++
 models/ssh_key.go                          | 21 ++++++++--
 modules/auth/user_form.go                  |  7 ++--
 options/locale/locale_en-US.ini            |  4 ++
 routers/api/v1/repo/key.go                 |  2 +-
 routers/repo/setting.go                    |  2 +-
 routers/repo/settings_test.go              | 61 ++++++++++++++++++++++++++++++
 templates/repo/settings/deploy_keys.tmpl   | 11 +++++-
 vendor/code.gitea.io/sdk/gitea/repo_key.go |  4 ++
 vendor/vendor.json                         |  6 +--
 13 files changed, 184 insertions(+), 13 deletions(-)
 create mode 100644 models/fixtures/deploy_key.yml
 create mode 100644 models/migrations/v55.go
 create mode 100644 routers/repo/settings_test.go

diff --git a/integrations/api_keys_test.go b/integrations/api_keys_test.go
index 8e2b6d3fbf..b2ae1035ce 100644
--- a/integrations/api_keys_test.go
+++ b/integrations/api_keys_test.go
@@ -5,9 +5,11 @@
 package integrations
 
 import (
+	"fmt"
 	"net/http"
 	"testing"
 
+	"code.gitea.io/gitea/models"
 	api "code.gitea.io/sdk/gitea"
 )
 
@@ -37,3 +39,54 @@ func TestDeleteDeployKeyNoLogin(t *testing.T) {
 	req := NewRequest(t, "DELETE", "/api/v1/repos/user2/repo1/keys/1")
 	MakeRequest(t, req, http.StatusUnauthorized)
 }
+
+func TestCreateReadOnlyDeployKey(t *testing.T) {
+	prepareTestEnv(t)
+	repo := models.AssertExistsAndLoadBean(t, &models.Repository{Name: "repo1"}).(*models.Repository)
+	repoOwner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User)
+
+	session := loginUser(t, repoOwner.Name)
+
+	keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/keys", repoOwner.Name, repo.Name)
+	rawKeyBody := api.CreateKeyOption{
+		Title:    "read-only",
+		Key:      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n",
+		ReadOnly: true,
+	}
+	req := NewRequestWithJSON(t, "POST", keysURL, rawKeyBody)
+	resp := session.MakeRequest(t, req, http.StatusCreated)
+
+	var newDeployKey api.DeployKey
+	DecodeJSON(t, resp, &newDeployKey)
+	models.AssertExistsAndLoadBean(t, &models.DeployKey{
+		ID:      newDeployKey.ID,
+		Name:    rawKeyBody.Title,
+		Content: rawKeyBody.Key,
+		Mode:    models.AccessModeRead,
+	})
+}
+
+func TestCreateReadWriteDeployKey(t *testing.T) {
+	prepareTestEnv(t)
+	repo := models.AssertExistsAndLoadBean(t, &models.Repository{Name: "repo1"}).(*models.Repository)
+	repoOwner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User)
+
+	session := loginUser(t, repoOwner.Name)
+
+	keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/keys", repoOwner.Name, repo.Name)
+	rawKeyBody := api.CreateKeyOption{
+		Title: "read-write",
+		Key:   "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDsufOCrDDlT8DLkodnnJtbq7uGflcPae7euTfM+Laq4So+v4WeSV362Rg0O/+Sje1UthrhN6lQkfRkdWIlCRQEXg+LMqr6RhvDfZquE2Xwqv/itlz7LjbdAUdYoO1iH7rMSmYvQh4WEnC/DAacKGbhdGIM/ZBz0z6tHm7bPgbI9ykEKekTmPwQFP1Qebvf5NYOFMWqQ2sCEAI9dBMVLoojsIpV+KADf+BotiIi8yNfTG2rzmzpxBpW9fYjd1Sy1yd4NSUpoPbEJJYJ1TrjiSWlYOVq9Ar8xW1O87i6gBjL/3zN7ANeoYhaAXupdOS6YL22YOK/yC0tJtXwwdh/eSrh",
+	}
+	req := NewRequestWithJSON(t, "POST", keysURL, rawKeyBody)
+	resp := session.MakeRequest(t, req, http.StatusCreated)
+
+	var newDeployKey api.DeployKey
+	DecodeJSON(t, resp, &newDeployKey)
+	models.AssertExistsAndLoadBean(t, &models.DeployKey{
+		ID:      newDeployKey.ID,
+		Name:    rawKeyBody.Title,
+		Content: rawKeyBody.Key,
+		Mode:    models.AccessModeWrite,
+	})
+}
diff --git a/models/fixtures/deploy_key.yml b/models/fixtures/deploy_key.yml
new file mode 100644
index 0000000000..ca780a73aa
--- /dev/null
+++ b/models/fixtures/deploy_key.yml
@@ -0,0 +1 @@
+[] # empty
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index 90f286056f..37f3717ff4 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -162,6 +162,8 @@ var migrations = []Migration{
 	NewMigration("add reactions", addReactions),
 	// v54 -> v55
 	NewMigration("add pull request options", addPullRequestOptions),
+	// v55 -> v56
+	NewMigration("add writable deploy keys", addModeToDeploKeys),
 }
 
 // Migrate database to current version
diff --git a/models/migrations/v55.go b/models/migrations/v55.go
new file mode 100644
index 0000000000..32f4e8ac04
--- /dev/null
+++ b/models/migrations/v55.go
@@ -0,0 +1,23 @@
+// Copyright 2018 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 (
+	"fmt"
+
+	"code.gitea.io/gitea/models"
+	"github.com/go-xorm/xorm"
+)
+
+func addModeToDeploKeys(x *xorm.Engine) error {
+	type DeployKey struct {
+		Mode models.AccessMode `xorm:"NOT NULL DEFAULT 1"`
+	}
+
+	if err := x.Sync2(new(DeployKey)); err != nil {
+		return fmt.Errorf("Sync2: %v", err)
+	}
+	return nil
+}
diff --git a/models/ssh_key.go b/models/ssh_key.go
index 4d276ebeb7..2878177d44 100644
--- a/models/ssh_key.go
+++ b/models/ssh_key.go
@@ -600,6 +600,8 @@ type DeployKey struct {
 	Fingerprint string
 	Content     string `xorm:"-"`
 
+	Mode AccessMode `xorm:"NOT NULL DEFAULT 1"`
+
 	CreatedUnix       util.TimeStamp `xorm:"created"`
 	UpdatedUnix       util.TimeStamp `xorm:"updated"`
 	HasRecentActivity bool           `xorm:"-"`
@@ -622,6 +624,11 @@ func (key *DeployKey) GetContent() error {
 	return nil
 }
 
+// IsReadOnly checks if the key can only be used for read operations
+func (key *DeployKey) IsReadOnly() bool {
+	return key.Mode == AccessModeRead
+}
+
 func checkDeployKey(e Engine, keyID, repoID int64, name string) error {
 	// Note: We want error detail, not just true or false here.
 	has, err := e.
@@ -646,7 +653,7 @@ func checkDeployKey(e Engine, keyID, repoID int64, name string) error {
 }
 
 // addDeployKey adds new key-repo relation.
-func addDeployKey(e *xorm.Session, keyID, repoID int64, name, fingerprint string) (*DeployKey, error) {
+func addDeployKey(e *xorm.Session, keyID, repoID int64, name, fingerprint string, mode AccessMode) (*DeployKey, error) {
 	if err := checkDeployKey(e, keyID, repoID, name); err != nil {
 		return nil, err
 	}
@@ -656,6 +663,7 @@ func addDeployKey(e *xorm.Session, keyID, repoID int64, name, fingerprint string
 		RepoID:      repoID,
 		Name:        name,
 		Fingerprint: fingerprint,
+		Mode:        mode,
 	}
 	_, err := e.Insert(key)
 	return key, err
@@ -670,15 +678,20 @@ func HasDeployKey(keyID, repoID int64) bool {
 }
 
 // AddDeployKey add new deploy key to database and authorized_keys file.
-func AddDeployKey(repoID int64, name, content string) (*DeployKey, error) {
+func AddDeployKey(repoID int64, name, content string, readOnly bool) (*DeployKey, error) {
 	fingerprint, err := calcFingerprint(content)
 	if err != nil {
 		return nil, err
 	}
 
+	accessMode := AccessModeRead
+	if !readOnly {
+		accessMode = AccessModeWrite
+	}
+
 	pkey := &PublicKey{
 		Fingerprint: fingerprint,
-		Mode:        AccessModeRead,
+		Mode:        accessMode,
 		Type:        KeyTypeDeploy,
 	}
 	has, err := x.Get(pkey)
@@ -701,7 +714,7 @@ func AddDeployKey(repoID int64, name, content string) (*DeployKey, error) {
 		}
 	}
 
-	key, err := addDeployKey(sess, pkey.ID, repoID, name, pkey.Fingerprint)
+	key, err := addDeployKey(sess, pkey.ID, repoID, name, pkey.Fingerprint, accessMode)
 	if err != nil {
 		return nil, fmt.Errorf("addDeployKey: %v", err)
 	}
diff --git a/modules/auth/user_form.go b/modules/auth/user_form.go
index ab0bb1e7dd..d913822a8d 100644
--- a/modules/auth/user_form.go
+++ b/modules/auth/user_form.go
@@ -169,9 +169,10 @@ func (f *AddOpenIDForm) Validate(ctx *macaron.Context, errs binding.Errors) bind
 
 // AddKeyForm form for adding SSH/GPG key
 type AddKeyForm struct {
-	Type    string `binding:"OmitEmpty"`
-	Title   string `binding:"Required;MaxSize(50)"`
-	Content string `binding:"Required"`
+	Type       string `binding:"OmitEmpty"`
+	Title      string `binding:"Required;MaxSize(50)"`
+	Content    string `binding:"Required"`
+	IsWritable bool
 }
 
 // Validate validates the fields
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 4344a65774..a1cb077599 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -401,6 +401,8 @@ valid_until = Valid until
 valid_forever = Valid forever
 last_used = Last used on
 no_activity = No recent activity
+can_read_info = Read
+can_write_info = Write
 key_state_desc = This key has been used in the last 7 days
 token_state_desc = This token has been used in the last 7 days
 show_openid = Show on profile
@@ -995,6 +997,8 @@ settings.add_dingtalk_hook_desc = Add <a href="%s">Dingtalk</a> integration to y
 settings.deploy_keys = Deploy Keys
 settings.add_deploy_key = Add Deploy Key
 settings.deploy_key_desc = Deploy keys have read-only access. They are not the same as personal account SSH keys.
+settings.is_writable = Allow write access
+settings.is_writable_info = Can this key be used to <strong>push</strong> to this repository? Deploy keys always have pull access.
 settings.no_deploy_keys = You haven't added any deploy keys.
 settings.title = Title
 settings.deploy_key_content = Content
diff --git a/routers/api/v1/repo/key.go b/routers/api/v1/repo/key.go
index 42082c3561..a3586d2552 100644
--- a/routers/api/v1/repo/key.go
+++ b/routers/api/v1/repo/key.go
@@ -160,7 +160,7 @@ func CreateDeployKey(ctx *context.APIContext, form api.CreateKeyOption) {
 		return
 	}
 
-	key, err := models.AddDeployKey(ctx.Repo.Repository.ID, form.Title, content)
+	key, err := models.AddDeployKey(ctx.Repo.Repository.ID, form.Title, content, form.ReadOnly)
 	if err != nil {
 		HandleAddKeyError(ctx, err)
 		return
diff --git a/routers/repo/setting.go b/routers/repo/setting.go
index 342451b8ad..d7a61ba268 100644
--- a/routers/repo/setting.go
+++ b/routers/repo/setting.go
@@ -544,7 +544,7 @@ func DeployKeysPost(ctx *context.Context, form auth.AddKeyForm) {
 		return
 	}
 
-	key, err := models.AddDeployKey(ctx.Repo.Repository.ID, form.Title, content)
+	key, err := models.AddDeployKey(ctx.Repo.Repository.ID, form.Title, content, !form.IsWritable)
 	if err != nil {
 		ctx.Data["HasError"] = true
 		switch {
diff --git a/routers/repo/settings_test.go b/routers/repo/settings_test.go
new file mode 100644
index 0000000000..392c05f773
--- /dev/null
+++ b/routers/repo/settings_test.go
@@ -0,0 +1,61 @@
+// Copyright 2017 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 repo
+
+import (
+	"net/http"
+	"testing"
+
+	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/modules/auth"
+	"code.gitea.io/gitea/modules/test"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAddReadOnlyDeployKey(t *testing.T) {
+	models.PrepareTestEnv(t)
+
+	ctx := test.MockContext(t, "user2/repo1/settings/keys")
+
+	test.LoadUser(t, ctx, 2)
+	test.LoadRepo(t, ctx, 2)
+
+	addKeyForm := auth.AddKeyForm{
+		Title:   "read-only",
+		Content: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n",
+	}
+	DeployKeysPost(ctx, addKeyForm)
+	assert.EqualValues(t, http.StatusFound, ctx.Resp.Status())
+
+	models.AssertExistsAndLoadBean(t, &models.DeployKey{
+		Name:    addKeyForm.Title,
+		Content: addKeyForm.Content,
+		Mode:    models.AccessModeRead,
+	})
+}
+
+func TestAddReadWriteOnlyDeployKey(t *testing.T) {
+	models.PrepareTestEnv(t)
+
+	ctx := test.MockContext(t, "user2/repo1/settings/keys")
+
+	test.LoadUser(t, ctx, 2)
+	test.LoadRepo(t, ctx, 2)
+
+	addKeyForm := auth.AddKeyForm{
+		Title:      "read-write",
+		Content:    "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n",
+		IsWritable: true,
+	}
+	DeployKeysPost(ctx, addKeyForm)
+	assert.EqualValues(t, http.StatusFound, ctx.Resp.Status())
+
+	models.AssertExistsAndLoadBean(t, &models.DeployKey{
+		Name:    addKeyForm.Title,
+		Content: addKeyForm.Content,
+		Mode:    models.AccessModeWrite,
+	})
+}
diff --git a/templates/repo/settings/deploy_keys.tmpl b/templates/repo/settings/deploy_keys.tmpl
index 3b22be532b..f19055f2b5 100644
--- a/templates/repo/settings/deploy_keys.tmpl
+++ b/templates/repo/settings/deploy_keys.tmpl
@@ -31,7 +31,7 @@
 										{{.Fingerprint}}
 									</div>
 									<div class="activity meta">
-										<i>{{$.i18n.Tr "settings.add_on"}} <span>{{.CreatedUnix.FormatShort}}</span> —  <i class="octicon octicon-info"></i> {{if .HasUsed}}{{$.i18n.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{.UpdatedUnix.FormatShort}}</span>{{else}}{{$.i18n.Tr "settings.no_activity"}}{{end}}</i>
+										<i>{{$.i18n.Tr "settings.add_on"}} <span>{{.CreatedUnix.FormatShort}}</span> —  <i class="octicon octicon-info"></i> {{if .HasUsed}}{{$.i18n.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{.UpdatedUnix.FormatShort}}</span>{{else}}{{$.i18n.Tr "settings.no_activity"}}{{end}} - <span>{{$.i18n.Tr "settings.can_read_info"}}{{if not .IsReadOnly}} / {{$.i18n.Tr "settings.can_write_info"}} {{end}}</i>
 									</div>
 								</div>
 						</div>
@@ -60,6 +60,15 @@
 						<label for="content">{{.i18n.Tr "repo.settings.deploy_key_content"}}</label>
 						<textarea id="ssh-key-content" name="content" required>{{.content}}</textarea>
 					</div>
+					<div class="field">
+						<div class="ui checkbox {{if .Err_IsWritable}}error{{end}}">
+							<input id="ssh-key-is-writable" name="is_writable" class="hidden" type="checkbox" value="1">
+							<label for="is_writable">
+								{{.i18n.Tr "repo.settings.is_writable"}}
+							</label>
+							<small style="padding-left: 26px;">{{$.i18n.Tr "repo.settings.is_writable_info" | Str2html}}</small>
+						</div>
+					</div>
 					<button class="ui green button">
 						{{.i18n.Tr "repo.settings.add_deploy_key"}}
 					</button>
diff --git a/vendor/code.gitea.io/sdk/gitea/repo_key.go b/vendor/code.gitea.io/sdk/gitea/repo_key.go
index 03f626cd6e..f2f1038aa3 100644
--- a/vendor/code.gitea.io/sdk/gitea/repo_key.go
+++ b/vendor/code.gitea.io/sdk/gitea/repo_key.go
@@ -46,6 +46,10 @@ type CreateKeyOption struct {
 	// required: true
 	// unique: true
 	Key string `json:"key" binding:"Required"`
+	// Describe if the key has only read access or read/write
+	//
+	// required: false
+	ReadOnly bool `json:"read_only"`
 }
 
 // CreateDeployKey options when create one deploy key
diff --git a/vendor/vendor.json b/vendor/vendor.json
index 893110ac71..828bdd4e43 100644
--- a/vendor/vendor.json
+++ b/vendor/vendor.json
@@ -9,10 +9,10 @@
 			"revisionTime": "2017-12-22T02:43:26Z"
 		},
 		{
-			"checksumSHA1": "QQ7g7B9+EIzGjO14KCGEs9TNEzM=",
+			"checksumSHA1": "Qtq0kW+BnpYMOriaoCjMa86WGG8=",
 			"path": "code.gitea.io/sdk/gitea",
-			"revision": "ec7d3af43b598c1a3f2cb12f633b9625649d8e54",
-			"revisionTime": "2017-11-28T12:30:39Z"
+			"revision": "79eee8f12c7fc1cc5b802c5cdc5b494ef3733866",
+			"revisionTime": "2017-12-20T06:57:50Z"
 		},
 		{
 			"checksumSHA1": "bOODD4Gbw3GfcuQPU2dI40crxxk=",