diff --git a/go/internal/command/command.go b/go/internal/command/command.go index 7bc1994718fef625bd55b10683cf55915d428586..a1dde423b3af95e15ab1c9d84f81a38c439613b7 100644 --- a/go/internal/command/command.go +++ b/go/internal/command/command.go @@ -4,6 +4,7 @@ import ( "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/discover" "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/fallback" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/lfsauthenticate" "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/receivepack" "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/twofactorrecover" @@ -38,6 +39,8 @@ func buildCommand(args *commandargs.CommandArgs, config *config.Config, readWrit return &discover.Command{Config: config, Args: args, ReadWriter: readWriter} case commandargs.TwoFactorRecover: return &twofactorrecover.Command{Config: config, Args: args, ReadWriter: readWriter} + case commandargs.LfsAuthenticate: + return &lfsauthenticate.Command{Config: config, Args: args, ReadWriter: readWriter} case commandargs.ReceivePack: return &receivepack.Command{Config: config, Args: args, ReadWriter: readWriter} case commandargs.UploadPack: diff --git a/go/internal/command/command_test.go b/go/internal/command/command_test.go index cbdfc56c84e15071335c6b5877eef232dd654b47..07260dd20f281044994bd4ab80ba7e1ca1364dfa 100644 --- a/go/internal/command/command_test.go +++ b/go/internal/command/command_test.go @@ -7,6 +7,7 @@ import ( "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/discover" "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/fallback" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/lfsauthenticate" "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/receivepack" "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/twofactorrecover" "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/uploadarchive" @@ -58,6 +59,18 @@ func TestNew(t *testing.T) { }, expectedType: &twofactorrecover.Command{}, }, + { + desc: "it returns an LfsAuthenticate command if the feature is enabled", + config: &config.Config{ + GitlabUrl: "http+unix://gitlab.socket", + Migration: config.MigrationConfig{Enabled: true, Features: []string{"git-lfs-authenticate"}}, + }, + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": "git-lfs-authenticate", + }, + expectedType: &lfsauthenticate.Command{}, + }, { desc: "it returns a ReceivePack command if the feature is enabled", config: &config.Config{ diff --git a/go/internal/command/commandargs/command_args.go b/go/internal/command/commandargs/command_args.go index fd9d7413cef412cec224ccf69f3cd4b06fb43baf..d8fe32d9571f1668fab3737a128fa83783d87d1b 100644 --- a/go/internal/command/commandargs/command_args.go +++ b/go/internal/command/commandargs/command_args.go @@ -13,6 +13,7 @@ type CommandType string 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" diff --git a/go/internal/command/commandargs/command_args_test.go b/go/internal/command/commandargs/command_args_test.go index 7c360ade58a33a5bedb4fbfdc2888fbfe3123d14..e60bb9272ac88141b0849e325b3a23fd55209d53 100644 --- a/go/internal/command/commandargs/command_args_test.go +++ b/go/internal/command/commandargs/command_args_test.go @@ -90,6 +90,13 @@ func TestParseSuccess(t *testing.T) { "SSH_ORIGINAL_COMMAND": "git-upload-archive 'group/repo'", }, expectedArgs: &CommandArgs{SshArgs: []string{"git-upload-archive", "group/repo"}, CommandType: UploadArchive}, + }, { + desc: "It parses git-lfs-authenticate command", + environment: map[string]string{ + "SSH_CONNECTION": "1", + "SSH_ORIGINAL_COMMAND": "git-lfs-authenticate 'group/repo' download", + }, + expectedArgs: &CommandArgs{SshArgs: []string{"git-lfs-authenticate", "group/repo", "download"}, CommandType: LfsAuthenticate}, }, } diff --git a/go/internal/command/lfsauthenticate/lfsauthenticate.go b/go/internal/command/lfsauthenticate/lfsauthenticate.go new file mode 100644 index 0000000000000000000000000000000000000000..c1dc45f77915ecc6ca4e8282dcaa395d7b7b151f --- /dev/null +++ b/go/internal/command/lfsauthenticate/lfsauthenticate.go @@ -0,0 +1,104 @@ +package lfsauthenticate + +import ( + "encoding/base64" + "encoding/json" + "fmt" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/accessverifier" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/shared/disallowedcommand" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/lfsauthenticate" +) + +const ( + downloadAction = "download" + uploadAction = "upload" +) + +type Command struct { + Config *config.Config + Args *commandargs.CommandArgs + ReadWriter *readwriter.ReadWriter +} + +type PayloadHeader struct { + Auth string `json:"Authorization"` +} + +type Payload struct { + Header PayloadHeader `json:"header"` + Href string `json:"href"` + ExpiresIn int `json:"expires_in,omitempty"` +} + +func (c *Command) Execute() error { + args := c.Args.SshArgs + if len(args) < 3 { + return disallowedcommand.Error + } + + repo := args[1] + action, err := actionToCommandType(args[2]) + if err != nil { + return err + } + + accessResponse, err := c.verifyAccess(action, repo) + if err != nil { + return err + } + + payload, err := c.authenticate(action, repo, accessResponse.UserId) + if err != nil { + // return nothing just like Ruby's GitlabShell#lfs_authenticate does + return nil + } + + fmt.Fprintf(c.ReadWriter.Out, "%s\n", payload) + + return nil +} + +func actionToCommandType(action string) (commandargs.CommandType, error) { + var accessAction commandargs.CommandType + switch action { + case downloadAction: + accessAction = commandargs.UploadPack + case uploadAction: + accessAction = commandargs.ReceivePack + default: + return "", disallowedcommand.Error + } + + return accessAction, nil +} + +func (c *Command) verifyAccess(action commandargs.CommandType, repo string) (*accessverifier.Response, error) { + cmd := accessverifier.Command{c.Config, c.Args, c.ReadWriter} + + return cmd.Verify(action, repo) +} + +func (c *Command) authenticate(action commandargs.CommandType, repo, userId string) ([]byte, error) { + client, err := lfsauthenticate.NewClient(c.Config, c.Args) + if err != nil { + return nil, err + } + + response, err := client.Authenticate(action, repo, userId) + if err != nil { + return nil, err + } + + basicAuth := base64.StdEncoding.EncodeToString([]byte(response.Username + ":" + response.LfsToken)) + payload := &Payload{ + Header: PayloadHeader{Auth: "Basic " + basicAuth}, + Href: response.RepoPath + "/info/lfs", + ExpiresIn: response.ExpiresIn, + } + + return json.Marshal(payload) +} diff --git a/go/internal/command/lfsauthenticate/lfsauthenticate_test.go b/go/internal/command/lfsauthenticate/lfsauthenticate_test.go new file mode 100644 index 0000000000000000000000000000000000000000..30da94b3520791b3fb0723a71c2e908d2aabf155 --- /dev/null +++ b/go/internal/command/lfsauthenticate/lfsauthenticate_test.go @@ -0,0 +1,153 @@ +package lfsauthenticate + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/readwriter" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/accessverifier" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/lfsauthenticate" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/testhelper/requesthandlers" +) + +func TestFailedRequests(t *testing.T) { + requests := requesthandlers.BuildDisallowedByApiHandlers(t) + url, cleanup := testserver.StartHttpServer(t, requests) + defer cleanup() + + testCases := []struct { + desc string + arguments *commandargs.CommandArgs + expectedOutput string + }{ + { + desc: "With missing arguments", + arguments: &commandargs.CommandArgs{}, + expectedOutput: "> GitLab: Disallowed command", + }, + { + desc: "With disallowed command", + arguments: &commandargs.CommandArgs{GitlabKeyId: "1", SshArgs: []string{"git-lfs-authenticate", "group/repo", "unknown"}}, + expectedOutput: "> GitLab: Disallowed command", + }, + { + desc: "With disallowed user", + arguments: &commandargs.CommandArgs{GitlabKeyId: "disallowed", SshArgs: []string{"git-lfs-authenticate", "group/repo", "download"}}, + expectedOutput: "Disallowed by API call", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + output := &bytes.Buffer{} + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: tc.arguments, + ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output}, + } + + err := cmd.Execute() + require.Error(t, err) + + require.Equal(t, tc.expectedOutput, err.Error()) + }) + } +} + +func TestLfsAuthenticateRequests(t *testing.T) { + userId := "123" + + requests := []testserver.TestRequestHandler{ + { + Path: "/api/v4/internal/lfs_authenticate", + Handler: func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + require.NoError(t, err) + + var request *lfsauthenticate.Request + require.NoError(t, json.Unmarshal(b, &request)) + + if request.UserId == userId { + body := map[string]interface{}{ + "username": "john", + "lfs_token": "sometoken", + "repository_http_path": "https://gitlab.com/repo/path", + "expires_in": 1800, + } + require.NoError(t, json.NewEncoder(w).Encode(body)) + } else { + w.WriteHeader(http.StatusForbidden) + } + }, + }, + { + Path: "/api/v4/internal/allowed", + Handler: func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + require.NoError(t, err) + + var request *accessverifier.Request + require.NoError(t, json.Unmarshal(b, &request)) + + var glId string + if request.Username == "somename" { + glId = userId + } else { + glId = "100" + } + + body := map[string]interface{}{ + "gl_id": glId, + "status": true, + } + require.NoError(t, json.NewEncoder(w).Encode(body)) + }, + }, + } + + url, cleanup := testserver.StartHttpServer(t, requests) + defer cleanup() + + testCases := []struct { + desc string + username string + expectedOutput string + }{ + { + desc: "With successful response from API", + username: "somename", + expectedOutput: "{\"header\":{\"Authorization\":\"Basic am9objpzb21ldG9rZW4=\"},\"href\":\"https://gitlab.com/repo/path/info/lfs\",\"expires_in\":1800}\n", + }, + { + desc: "With forbidden response from API", + username: "anothername", + expectedOutput: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + output := &bytes.Buffer{} + cmd := &Command{ + Config: &config.Config{GitlabUrl: url}, + Args: &commandargs.CommandArgs{GitlabUsername: tc.username, SshArgs: []string{"git-lfs-authenticate", "group/repo", "upload"}}, + ReadWriter: &readwriter.ReadWriter{ErrOut: output, Out: output}, + } + + err := cmd.Execute() + require.NoError(t, err) + + require.Equal(t, tc.expectedOutput, output.String()) + }) + } +} diff --git a/go/internal/gitlabnet/lfsauthenticate/client.go b/go/internal/gitlabnet/lfsauthenticate/client.go new file mode 100644 index 0000000000000000000000000000000000000000..2a7cb03798db9006324ee53b25b8835cbff6c483 --- /dev/null +++ b/go/internal/gitlabnet/lfsauthenticate/client.go @@ -0,0 +1,66 @@ +package lfsauthenticate + +import ( + "fmt" + "net/http" + "strings" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet" +) + +type Client struct { + config *config.Config + client *gitlabnet.GitlabClient + args *commandargs.CommandArgs +} + +type Request struct { + Action commandargs.CommandType `json:"operation"` + Repo string `json:"project"` + KeyId string `json:"key_id,omitempty"` + UserId string `json:"user_id,omitempty"` +} + +type Response struct { + Username string `json:"username"` + LfsToken string `json:"lfs_token"` + RepoPath string `json:"repository_http_path"` + ExpiresIn int `json:"expires_in"` +} + +func NewClient(config *config.Config, args *commandargs.CommandArgs) (*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, args: args}, nil +} + +func (c *Client) Authenticate(action commandargs.CommandType, repo, userId string) (*Response, error) { + request := &Request{Action: action, Repo: repo} + if c.args.GitlabKeyId != "" { + request.KeyId = c.args.GitlabKeyId + } else { + request.UserId = strings.TrimPrefix(userId, "user-") + } + + response, err := c.client.Post("/lfs_authenticate", request) + 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 + } + + return response, nil +} diff --git a/go/internal/gitlabnet/lfsauthenticate/client_test.go b/go/internal/gitlabnet/lfsauthenticate/client_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7fd7aca2f92c587df47e738a932d8e49a8b953b5 --- /dev/null +++ b/go/internal/gitlabnet/lfsauthenticate/client_test.go @@ -0,0 +1,117 @@ +package lfsauthenticate + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-shell/go/internal/command/commandargs" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/config" + "gitlab.com/gitlab-org/gitlab-shell/go/internal/gitlabnet/testserver" +) + +const ( + keyId = "123" + repo = "group/repo" + action = commandargs.UploadPack +) + +func setup(t *testing.T) []testserver.TestRequestHandler { + requests := []testserver.TestRequestHandler{ + { + Path: "/api/v4/internal/lfs_authenticate", + Handler: func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + defer r.Body.Close() + require.NoError(t, err) + + var request *Request + require.NoError(t, json.Unmarshal(b, &request)) + + switch request.KeyId { + case keyId: + body := map[string]interface{}{ + "username": "john", + "lfs_token": "sometoken", + "repository_http_path": "https://gitlab.com/repo/path", + "expires_in": 1800, + } + require.NoError(t, json.NewEncoder(w).Encode(body)) + case "forbidden": + w.WriteHeader(http.StatusForbidden) + case "broken": + w.WriteHeader(http.StatusInternalServerError) + } + }, + }, + } + + return requests +} + +func TestFailedRequests(t *testing.T) { + requests := setup(t) + url, cleanup := testserver.StartHttpServer(t, requests) + defer cleanup() + + testCases := []struct { + desc string + args *commandargs.CommandArgs + expectedOutput string + }{ + { + desc: "With bad response", + args: &commandargs.CommandArgs{GitlabKeyId: "-1", CommandType: commandargs.UploadPack}, + expectedOutput: "Parsing failed", + }, + { + desc: "With API returns an error", + args: &commandargs.CommandArgs{GitlabKeyId: "forbidden", CommandType: commandargs.UploadPack}, + expectedOutput: "Internal API error (403)", + }, + { + desc: "With API fails", + args: &commandargs.CommandArgs{GitlabKeyId: "broken", CommandType: commandargs.UploadPack}, + expectedOutput: "Internal API error (500)", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + client, err := NewClient(&config.Config{GitlabUrl: url}, tc.args) + require.NoError(t, err) + + repo := "group/repo" + + _, err = client.Authenticate(tc.args.CommandType, repo, "") + require.Error(t, err) + + require.Equal(t, tc.expectedOutput, err.Error()) + }) + } +} + +func TestSuccessfulRequests(t *testing.T) { + requests := setup(t) + url, cleanup := testserver.StartHttpServer(t, requests) + defer cleanup() + + args := &commandargs.CommandArgs{GitlabKeyId: keyId, CommandType: commandargs.LfsAuthenticate} + client, err := NewClient(&config.Config{GitlabUrl: url}, args) + require.NoError(t, err) + + response, err := client.Authenticate(action, repo, "") + require.NoError(t, err) + + expectedResponse := &Response{ + Username: "john", + LfsToken: "sometoken", + RepoPath: "https://gitlab.com/repo/path", + ExpiresIn: 1800, + } + + require.Equal(t, expectedResponse, response) +} diff --git a/spec/gitlab_shell_lfs_authentication_spec.rb b/spec/gitlab_shell_lfs_authentication_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7cdb320146d11c78690e7ef75a09131d227aae78 --- /dev/null +++ b/spec/gitlab_shell_lfs_authentication_spec.rb @@ -0,0 +1,146 @@ +require_relative 'spec_helper' + +require 'open3' + +describe 'bin/gitlab-shell git-lfs-authentication' do + include_context 'gitlab shell' + + let(:path) { "https://gitlab.com/repo/path" } + + def mock_server(server) + server.mount_proc('/api/v4/internal/lfs_authenticate') do |req, res| + res.content_type = 'application/json' + + key_id = req.query['key_id'] || req.query['user_id'] + + unless key_id + body = JSON.parse(req.body) + key_id = body['key_id'] || body['user_id'].to_s + end + + if key_id == '100' + res.status = 200 + res.body = %{{"username":"john","lfs_token":"sometoken","repository_http_path":"#{path}","expires_in":1800}} + else + res.status = 403 + end + end + + server.mount_proc('/api/v4/internal/allowed') do |req, res| + res.content_type = 'application/json' + + key_id = req.query['key_id'] || req.query['username'] + + unless key_id + body = JSON.parse(req.body) + key_id = body['key_id'] || body['username'].to_s + end + + case key_id + when '100', 'someone' then + res.status = 200 + res.body = '{"gl_id":"user-100", "status":true}' + when '101' then + res.status = 200 + res.body = '{"gl_id":"user-101", "status":true}' + else + res.status = 403 + end + end + end + + shared_examples 'lfs authentication command' do + def successful_response + { + "header" => { + "Authorization" => "Basic am9objpzb21ldG9rZW4=" + }, + "href" => "#{path}/info/lfs", + "expires_in" => 1800 + }.to_json + "\n" + end + + context 'when the command is allowed' do + context 'when key is provided' do + let(:cmd) { "#{gitlab_shell_path} key-100" } + + it 'lfs is successfully authenticated' do + output, stderr, status = Open3.capture3(env, cmd) + + expect(output).to eq(successful_response) + expect(status).to be_success + end + end + + context 'when username is provided' do + let(:cmd) { "#{gitlab_shell_path} username-someone" } + + it 'lfs is successfully authenticated' do + output, stderr, status = Open3.capture3(env, cmd) + + expect(output).to eq(successful_response) + expect(status).to be_success + end + end + end + + context 'when a user is not allowed to perform an action' do + let(:cmd) { "#{gitlab_shell_path} key-102" } + + it 'lfs is not authenticated' do + _, stderr, status = Open3.capture3(env, cmd) + + expect(stderr).not_to be_empty + expect(status).not_to be_success + end + end + + context 'when lfs authentication is forbidden for a user' do + let(:cmd) { "#{gitlab_shell_path} key-101" } + + it 'lfs is not authenticated' do + output, stderr, status = Open3.capture3(env, cmd) + + expect(stderr).to be_empty + expect(output).to be_empty + expect(status).to be_success + end + end + + context 'when an action for lfs authentication is unknown' do + let(:cmd) { "#{gitlab_shell_path} key-100" } + let(:env) { {'SSH_CONNECTION' => 'fake', 'SSH_ORIGINAL_COMMAND' => 'git-lfs-authenticate project/repo unknown' } } + + it 'the command is disallowed' do + _, stderr, status = Open3.capture3(env, cmd) + + expect(stderr).to eq("> GitLab: Disallowed command\n") + expect(status).not_to be_success + end + end + end + + let(:env) { {'SSH_CONNECTION' => 'fake', 'SSH_ORIGINAL_COMMAND' => 'git-lfs-authenticate project/repo download' } } + + describe 'without go features' do + before(:context) do + write_config( + "gitlab_url" => "http+unix://#{CGI.escape(tmp_socket_path)}", + ) + end + + it_behaves_like 'lfs authentication command' + end + + describe 'with go features' do + before(:context) do + write_config( + "gitlab_url" => "http+unix://#{CGI.escape(tmp_socket_path)}", + "migration" => { "enabled" => true, + "features" => ["git-lfs-authenticate"] } + ) + end + + it_behaves_like 'lfs authentication command' + end +end