diff options
author | Patrick Bajao <ebajao@gitlab.com> | 2020-08-18 06:54:30 +0000 |
---|---|---|
committer | Patrick Bajao <ebajao@gitlab.com> | 2020-08-18 06:54:30 +0000 |
commit | b972ea485ff85215049abe70e22f1db7242ee432 (patch) | |
tree | dd67dbef7c4c06e3a1ac5cf981be9ee37d355a03 | |
parent | 4b1ee791a1bdc927becee37ae84f7ba226d17791 (diff) | |
parent | b8d66d7923150402f54f13d793d3051efab3a832 (diff) | |
download | gitlab-shell-b972ea485ff85215049abe70e22f1db7242ee432.tar.gz |
Merge branch 'master' into 'master'
Add support obtaining personal access tokens via SSH
See merge request gitlab-org/gitlab-shell!397
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | internal/command/command.go | 3 | ||||
-rw-r--r-- | internal/command/command_test.go | 7 | ||||
-rw-r--r-- | internal/command/commandargs/shell.go | 13 | ||||
-rw-r--r-- | internal/command/personalaccesstoken/personalaccesstoken.go | 86 | ||||
-rw-r--r-- | internal/command/personalaccesstoken/personalaccesstoken_test.go | 186 | ||||
-rw-r--r-- | internal/gitlabnet/personalaccesstoken/client.go | 93 | ||||
-rw-r--r-- | internal/gitlabnet/personalaccesstoken/client_test.go | 177 | ||||
-rw-r--r-- | spec/gitlab_shell_personal_access_token_spec.rb | 119 |
9 files changed, 679 insertions, 6 deletions
@@ -3,6 +3,7 @@ cover.out tmp/* .idea *.log +*.swp /*.log* authorized_keys.lock .gitlab_shell_secret diff --git a/internal/command/command.go b/internal/command/command.go index af63862..283b4a1 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -7,6 +7,7 @@ import ( "gitlab.com/gitlab-org/gitlab-shell/internal/command/discover" "gitlab.com/gitlab-org/gitlab-shell/internal/command/healthcheck" "gitlab.com/gitlab-org/gitlab-shell/internal/command/lfsauthenticate" + "gitlab.com/gitlab-org/gitlab-shell/internal/command/personalaccesstoken" "gitlab.com/gitlab-org/gitlab-shell/internal/command/readwriter" "gitlab.com/gitlab-org/gitlab-shell/internal/command/receivepack" "gitlab.com/gitlab-org/gitlab-shell/internal/command/shared/disallowedcommand" @@ -63,6 +64,8 @@ func buildShellCommand(args *commandargs.Shell, config *config.Config, readWrite return &uploadpack.Command{Config: config, Args: args, ReadWriter: readWriter} case commandargs.UploadArchive: return &uploadarchive.Command{Config: config, Args: args, ReadWriter: readWriter} + case commandargs.PersonalAccessToken: + return &personalaccesstoken.Command{Config: config, Args: args, ReadWriter: readWriter} } return nil diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 2ca319e..db55e7d 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -11,6 +11,7 @@ import ( "gitlab.com/gitlab-org/gitlab-shell/internal/command/discover" "gitlab.com/gitlab-org/gitlab-shell/internal/command/healthcheck" "gitlab.com/gitlab-org/gitlab-shell/internal/command/lfsauthenticate" + "gitlab.com/gitlab-org/gitlab-shell/internal/command/personalaccesstoken" "gitlab.com/gitlab-org/gitlab-shell/internal/command/receivepack" "gitlab.com/gitlab-org/gitlab-shell/internal/command/shared/disallowedcommand" "gitlab.com/gitlab-org/gitlab-shell/internal/command/twofactorrecover" @@ -98,6 +99,12 @@ func TestNew(t *testing.T) { arguments: []string{"key", "principal"}, expectedType: &authorizedprincipals.Command{}, }, + { + desc: "it returns a PersonalAccessToken command", + executable: gitlabShellExec, + environment: buildEnv("personal_access_token"), + expectedType: &personalaccesstoken.Command{}, + }, } for _, tc := range testCases { diff --git a/internal/command/commandargs/shell.go b/internal/command/commandargs/shell.go index 1fe59fb..4632cff 100644 --- a/internal/command/commandargs/shell.go +++ b/internal/command/commandargs/shell.go @@ -9,12 +9,13 @@ import ( ) const ( - Discover CommandType = "discover" - TwoFactorRecover CommandType = "2fa_recovery_codes" - LfsAuthenticate CommandType = "git-lfs-authenticate" - ReceivePack CommandType = "git-receive-pack" - UploadPack CommandType = "git-upload-pack" - UploadArchive CommandType = "git-upload-archive" + Discover CommandType = "discover" + TwoFactorRecover CommandType = "2fa_recovery_codes" + LfsAuthenticate CommandType = "git-lfs-authenticate" + ReceivePack CommandType = "git-receive-pack" + UploadPack CommandType = "git-upload-pack" + UploadArchive CommandType = "git-upload-archive" + PersonalAccessToken CommandType = "personal_access_token" GitProtocolEnv = "GIT_PROTOCOL" ) diff --git a/internal/command/personalaccesstoken/personalaccesstoken.go b/internal/command/personalaccesstoken/personalaccesstoken.go new file mode 100644 index 0000000..b283890 --- /dev/null +++ b/internal/command/personalaccesstoken/personalaccesstoken.go @@ -0,0 +1,86 @@ +package personalaccesstoken + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "gitlab.com/gitlab-org/gitlab-shell/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/internal/gitlabnet/personalaccesstoken" +) + +const ( + usageText = "Usage: personal_access_token <name> <scope1[,scope2,...]> [ttl_days]" + expiresDateFormat = "2006-01-02" +) + +type Command struct { + Config *config.Config + Args *commandargs.Shell + ReadWriter *readwriter.ReadWriter + TokenArgs *tokenArgs +} + +type tokenArgs struct { + Name string + Scopes []string + ExpiresDate string // Calculated, a TTL is passed from command-line. +} + +func (c *Command) Execute() error { + err := c.parseTokenArgs() + if err != nil { + return err + } + + response, err := c.getPersonalAccessToken() + if err != nil { + return err + } + + fmt.Fprint(c.ReadWriter.Out, "Token: "+response.Token+"\n") + fmt.Fprint(c.ReadWriter.Out, "Scopes: "+strings.Join(response.Scopes, ",")+"\n") + if response.ExpiresAt == "" { + fmt.Fprint(c.ReadWriter.Out, "Expires: never\n") + } else { + fmt.Fprint(c.ReadWriter.Out, "Expires: "+response.ExpiresAt+"\n") + } + return nil +} + +func (c *Command) parseTokenArgs() error { + if len(c.Args.SshArgs) < 3 || len(c.Args.SshArgs) > 4 { + return errors.New(usageText) + } + c.TokenArgs = &tokenArgs{ + Name: c.Args.SshArgs[1], + Scopes: strings.Split(c.Args.SshArgs[2], ","), + } + + if len(c.Args.SshArgs) < 4 { + return nil + } + rawTTL := c.Args.SshArgs[3] + + TTL, err := strconv.Atoi(rawTTL) + if err != nil || TTL < 0 { + return fmt.Errorf("Invalid value for days_ttl: '%s'", rawTTL) + } + + c.TokenArgs.ExpiresDate = time.Now().AddDate(0, 0, TTL+1).Format(expiresDateFormat) + + return nil +} + +func (c *Command) getPersonalAccessToken() (*personalaccesstoken.Response, error) { + client, err := personalaccesstoken.NewClient(c.Config) + if err != nil { + return nil, err + } + + return client.GetPersonalAccessToken(c.Args, c.TokenArgs.Name, &c.TokenArgs.Scopes, c.TokenArgs.ExpiresDate) +} diff --git a/internal/command/personalaccesstoken/personalaccesstoken_test.go b/internal/command/personalaccesstoken/personalaccesstoken_test.go new file mode 100644 index 0000000..bc748ab --- /dev/null +++ b/internal/command/personalaccesstoken/personalaccesstoken_test.go @@ -0,0 +1,186 @@ +package personalaccesstoken + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/client/testserver" + "gitlab.com/gitlab-org/gitlab-shell/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/internal/gitlabnet/personalaccesstoken" +) + +var ( + requests []testserver.TestRequestHandler +) + +func setup(t *testing.T) { + requests = []testserver.TestRequestHandler{ + { + Path: "/api/v4/internal/personal_access_token", + Handler: func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + + require.NoError(t, err) + + var requestBody *personalaccesstoken.RequestBody + json.Unmarshal(b, &requestBody) + + switch requestBody.KeyId { + case "forbidden": + body := map[string]interface{}{ + "success": false, + "message": "Forbidden!", + } + json.NewEncoder(w).Encode(body) + case "broken": + w.WriteHeader(http.StatusInternalServerError) + case "badresponse": + default: + var expiresAt interface{} + if requestBody.ExpiresAt == "" { + expiresAt = nil + } else { + expiresAt = "9001-11-17" + } + body := map[string]interface{}{ + "success": true, + "token": "YXuxvUgCEmeePY3G1YAa", + "scopes": requestBody.Scopes, + "expires_at": expiresAt, + } + json.NewEncoder(w).Encode(body) + } + }, + }, + } +} + +const ( + cmdname = "personal_access_token" +) + +func TestExecute(t *testing.T) { + setup(t) + + url, cleanup := testserver.StartSocketHttpServer(t, requests) + defer cleanup() + + testCases := []struct { + desc string + arguments *commandargs.Shell + expectedOutput string + expectedError string + }{ + { + desc: "Without any arguments", + arguments: &commandargs.Shell{}, + expectedError: usageText, + }, + { + desc: "With too few arguments", + arguments: &commandargs.Shell{ + SshArgs: []string{cmdname, "newtoken"}, + }, + expectedError: usageText, + }, + { + desc: "With too many arguments", + arguments: &commandargs.Shell{ + SshArgs: []string{cmdname, "newtoken", "api", "bad_ttl", "toomany"}, + }, + expectedError: usageText, + }, + { + desc: "With a bad ttl_days argument", + arguments: &commandargs.Shell{ + SshArgs: []string{cmdname, "newtoken", "api", "bad_ttl"}, + }, + expectedError: "Invalid value for days_ttl: 'bad_ttl'", + }, + { + desc: "Without a ttl argument", + arguments: &commandargs.Shell{ + GitlabKeyId: "default", + SshArgs: []string{cmdname, "newtoken", "read_api,read_repository"}, + }, + expectedOutput: "Token: YXuxvUgCEmeePY3G1YAa\n" + + "Scopes: read_api,read_repository\n" + + "Expires: never\n", + }, + { + desc: "With a ttl argument", + arguments: &commandargs.Shell{ + GitlabKeyId: "default", + SshArgs: []string{cmdname, "newtoken", "api", "30"}, + }, + expectedOutput: "Token: YXuxvUgCEmeePY3G1YAa\n" + + "Scopes: api\n" + + "Expires: 9001-11-17\n", + }, + { + desc: "With bad response", + arguments: &commandargs.Shell{ + GitlabKeyId: "badresponse", + SshArgs: []string{cmdname, "newtoken", "read_api,read_repository"}, + }, + expectedError: "Parsing failed", + }, + { + desc: "when API returns an error", + arguments: &commandargs.Shell{ + GitlabKeyId: "forbidden", + SshArgs: []string{cmdname, "newtoken", "read_api,read_repository"}, + }, + expectedError: "Forbidden!", + }, + { + desc: "When API fails", + arguments: &commandargs.Shell{ + GitlabKeyId: "broken", + SshArgs: []string{cmdname, "newtoken", "read_api,read_repository"}, + }, + expectedError: "Internal API error (500)", + }, + { + desc: "Without KeyID or User", + arguments: &commandargs.Shell{ + SshArgs: []string{cmdname, "newtoken", "read_api,read_repository"}, + }, + expectedError: "who='' is invalid", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + output := &bytes.Buffer{} + input := bytes.NewBufferString("") + + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: tc.arguments, + ReadWriter: &readwriter.ReadWriter{Out: output, In: input}, + } + + err := cmd.Execute() + + if tc.expectedError == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tc.expectedError) + } + + if tc.expectedOutput != "" { + assert.Equal(t, tc.expectedOutput, output.String()) + } + }) + } +} diff --git a/internal/gitlabnet/personalaccesstoken/client.go b/internal/gitlabnet/personalaccesstoken/client.go new file mode 100644 index 0000000..588bead --- /dev/null +++ b/internal/gitlabnet/personalaccesstoken/client.go @@ -0,0 +1,93 @@ +package personalaccesstoken + +import ( + "errors" + "fmt" + "net/http" + + "gitlab.com/gitlab-org/gitlab-shell/client" + "gitlab.com/gitlab-org/gitlab-shell/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/internal/gitlabnet" + "gitlab.com/gitlab-org/gitlab-shell/internal/gitlabnet/discover" +) + +type Client struct { + config *config.Config + client *client.GitlabNetClient +} + +type Response struct { + Success bool `json:"success"` + Token string `json:"token"` + Scopes []string `json:"scopes"` + ExpiresAt string `json:"expires_at"` + Message string `json:"message"` +} + +type RequestBody struct { + KeyId string `json:"key_id,omitempty"` + UserId int64 `json:"user_id,omitempty"` + Name string `json:"name"` + Scopes []string `json:"scopes"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +func NewClient(config *config.Config) (*Client, error) { + client, err := gitlabnet.GetClient(config) + if err != nil { + return nil, fmt.Errorf("Error creating http client: %v", err) + } + + return &Client{config: config, client: client}, nil +} + +func (c *Client) GetPersonalAccessToken(args *commandargs.Shell, name string, scopes *[]string, expiresAt string) (*Response, error) { + requestBody, err := c.getRequestBody(args, name, scopes, expiresAt) + if err != nil { + return nil, err + } + + response, err := c.client.Post("/personal_access_token", requestBody) + if err != nil { + return nil, err + } + defer response.Body.Close() + + return parse(response) +} + +func parse(hr *http.Response) (*Response, error) { + response := &Response{} + if err := gitlabnet.ParseJSON(hr, response); err != nil { + return nil, err + } + + if !response.Success { + return nil, errors.New(response.Message) + } + + return response, nil +} + +func (c *Client) getRequestBody(args *commandargs.Shell, name string, scopes *[]string, expiresAt string) (*RequestBody, error) { + client, err := discover.NewClient(c.config) + if err != nil { + return nil, err + } + + requestBody := &RequestBody{Name: name, Scopes: *scopes, ExpiresAt: expiresAt} + if args.GitlabKeyId != "" { + requestBody.KeyId = args.GitlabKeyId + + return requestBody, nil + } + + userInfo, err := client.GetByCommandArgs(args) + if err != nil { + return nil, err + } + requestBody.UserId = userInfo.UserId + + return requestBody, nil +} diff --git a/internal/gitlabnet/personalaccesstoken/client_test.go b/internal/gitlabnet/personalaccesstoken/client_test.go new file mode 100644 index 0000000..de45975 --- /dev/null +++ b/internal/gitlabnet/personalaccesstoken/client_test.go @@ -0,0 +1,177 @@ +package personalaccesstoken + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/gitlab-shell/client" + "gitlab.com/gitlab-org/gitlab-shell/client/testserver" + "gitlab.com/gitlab-org/gitlab-shell/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/internal/gitlabnet/discover" +) + +var ( + requests []testserver.TestRequestHandler +) + +func initialize(t *testing.T) { + requests = []testserver.TestRequestHandler{ + { + Path: "/api/v4/internal/personal_access_token", + Handler: func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + + require.NoError(t, err) + + var requestBody *RequestBody + json.Unmarshal(b, &requestBody) + + switch requestBody.KeyId { + case "0": + body := map[string]interface{}{ + "success": true, + "token": "aAY1G3YPeemECgUvxuXY", + "scopes": [2]string{"read_api", "read_repository"}, + "expires_at": "9001-11-17", + } + json.NewEncoder(w).Encode(body) + case "1": + body := map[string]interface{}{ + "success": false, + "message": "missing user", + } + json.NewEncoder(w).Encode(body) + case "2": + w.WriteHeader(http.StatusForbidden) + body := &client.ErrorResponse{ + Message: "Not allowed!", + } + json.NewEncoder(w).Encode(body) + case "3": + w.Write([]byte("{ \"message\": \"broken json!\"")) + case "4": + w.WriteHeader(http.StatusForbidden) + } + + if requestBody.UserId == 1 { + body := map[string]interface{}{ + "success": true, + "token": "YXuxvUgCEmeePY3G1YAa", + "scopes": [1]string{"api"}, + "expires_at": nil, + } + json.NewEncoder(w).Encode(body) + } + }, + }, + { + Path: "/api/v4/internal/discover", + Handler: func(w http.ResponseWriter, r *http.Request) { + body := &discover.Response{ + UserId: 1, + Username: "jane-doe", + Name: "Jane Doe", + } + json.NewEncoder(w).Encode(body) + }, + }, + } +} + +func TestGetPersonalAccessTokenByKeyId(t *testing.T) { + client, cleanup := setup(t) + defer cleanup() + + args := &commandargs.Shell{GitlabKeyId: "0"} + result, err := client.GetPersonalAccessToken( + args, "newtoken", &[]string{"read_api", "read_repository"}, "", + ) + assert.NoError(t, err) + response := &Response{ + true, + "aAY1G3YPeemECgUvxuXY", + []string{"read_api", "read_repository"}, + "9001-11-17", + "", + } + assert.Equal(t, response, result) +} + +func TestGetRecoveryCodesByUsername(t *testing.T) { + client, cleanup := setup(t) + defer cleanup() + + args := &commandargs.Shell{GitlabUsername: "jane-doe"} + result, err := client.GetPersonalAccessToken( + args, "newtoken", &[]string{"api"}, "", + ) + assert.NoError(t, err) + response := &Response{true, "YXuxvUgCEmeePY3G1YAa", []string{"api"}, "", ""} + assert.Equal(t, response, result) +} + +func TestMissingUser(t *testing.T) { + client, cleanup := setup(t) + defer cleanup() + + args := &commandargs.Shell{GitlabKeyId: "1"} + _, err := client.GetPersonalAccessToken( + args, "newtoken", &[]string{"api"}, "", + ) + assert.Equal(t, "missing user", err.Error()) +} + +func TestErrorResponses(t *testing.T) { + client, cleanup := setup(t) + defer cleanup() + + testCases := []struct { + desc string + fakeId string + expectedError string + }{ + { + desc: "A response with an error message", + fakeId: "2", + expectedError: "Not allowed!", + }, + { + desc: "A response with bad JSON", + fakeId: "3", + expectedError: "Parsing failed", + }, + { + desc: "An error response without message", + fakeId: "4", + expectedError: "Internal API error (403)", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + args := &commandargs.Shell{GitlabKeyId: tc.fakeId} + resp, err := client.GetPersonalAccessToken( + args, "newtoken", &[]string{"api"}, "", + ) + + assert.EqualError(t, err, tc.expectedError) + assert.Nil(t, resp) + }) + } +} + +func setup(t *testing.T) (*Client, func()) { + initialize(t) + url, cleanup := testserver.StartSocketHttpServer(t, requests) + + client, err := NewClient(&config.Config{GitlabUrl: url}) + require.NoError(t, err) + + return client, cleanup +} diff --git a/spec/gitlab_shell_personal_access_token_spec.rb b/spec/gitlab_shell_personal_access_token_spec.rb new file mode 100644 index 0000000..64bc34b --- /dev/null +++ b/spec/gitlab_shell_personal_access_token_spec.rb @@ -0,0 +1,119 @@ +require_relative 'spec_helper' + +require 'json' +require 'open3' + +describe 'bin/gitlab-shell personal_access_token' do + include_context 'gitlab shell' + + before(:context) do + write_config("gitlab_url" => "http+unix://#{CGI.escape(tmp_socket_path)}") + end + + def mock_server(server) + server.mount_proc('/api/v4/internal/personal_access_token') do |req, res| + params = JSON.parse(req.body) + + res.content_type = 'application/json' + res.status = 200 + + if params['key_id'] == '000' + res.body = { success: false, message: "Something wrong!"}.to_json + else + res.body = { + success: true, + token: 'aAY1G3YPeemECgUvxuXY', + scopes: params['scopes'], + expires_at: (params['expires_at'] && '9001-12-01') + }.to_json + end + end + + server.mount_proc('/api/v4/internal/discover') do |req, res| + res.status = 200 + res.content_type = 'application/json' + res.body = '{"id":100, "name": "Some User", "username": "someuser"}' + end + end + + describe 'command' do + let(:key_id) { 'key-100' } + + let(:output) do + env = { + 'SSH_CONNECTION' => 'fake', + 'SSH_ORIGINAL_COMMAND' => "personal_access_token #{args}" + } + Open3.popen2e(env, "#{gitlab_shell_path} #{key_id}")[1].read() + end + + let(:help_message) do + <<~OUTPUT + remote: + remote: ======================================================================== + remote: + remote: Usage: personal_access_token <name> <scope1[,scope2,...]> [ttl_days] + remote: + remote: ======================================================================== + remote: + OUTPUT + end + + context 'without any arguments' do + let(:args) { '' } + + it 'prints the help message' do + expect(output).to eq(help_message) + end + end + + context 'with only the name argument' do + let(:args) { 'newtoken' } + + it 'prints the help message' do + expect(output).to eq(help_message) + end + end + + context 'without a ttl argument' do + let(:args) { 'newtoken api' } + + it 'prints a token without an expiration date' do + expect(output).to eq(<<~OUTPUT) + Token: aAY1G3YPeemECgUvxuXY + Scopes: api + Expires: never + OUTPUT + end + end + + context 'with a ttl argument' do + let(:args) { 'newtoken read_api,read_user 30' } + + it 'prints a token with an expiration date' do + expect(output).to eq(<<~OUTPUT) + Token: aAY1G3YPeemECgUvxuXY + Scopes: read_api,read_user + Expires: 9001-12-01 + OUTPUT + end + end + + context 'with an API error response' do + let(:args) { 'newtoken api' } + let(:key_id) { 'key-000' } + + it 'prints the error response' do + expect(output).to eq(<<~OUTPUT) + remote: + remote: ======================================================================== + remote: + remote: Something wrong! + remote: + remote: ======================================================================== + remote: + OUTPUT + end + end + end +end |