As we reevaluate how to best support and maintain Staging Ref in the future, we encourage development teams using this environment to highlight their use cases in the following issue: https://gitlab.com/gitlab-com/gl-infra/software-delivery/framework/software-delivery-framework-issue-tracker/-/issues/36.

Skip to content
Snippets Groups Projects
Commit b8d66d79 authored by Taylan Develioglu's avatar Taylan Develioglu
Browse files

Add support obtaining personal access tokens via SSH

Implements the feature requested in gitlab-org/gitlab#19672

This requires the internal api counterpart in gitlab-org/gitlab!36302 to
be merged first.

It can be used as follows:
```
censored@censored-VirtualBox:~/git/gitlab$ ssh git@gitlab-2004 personal_access_token
remote:
remote: ========================================================================
remote:
remote: Usage: personal_access_token <name> <scope1[,scope2,...]> [ttl_days]
remote:
remote: ========================================================================
remote:

censored@censored-VirtualBox:~/git/gitlab$ ssh git@gitlab-2004 personal_access_token newtoken read_api,read_repository 30
Token:   aAY1G3YPeemECgUvxuXY
Scopes:  read_api,read_repository
Expires: 2020-08-07
```
parent 4b1ee791
No related branches found
No related tags found
No related merge requests found
Loading
Loading
@@ -3,6 +3,7 @@ cover.out
tmp/*
.idea
*.log
*.swp
/*.log*
authorized_keys.lock
.gitlab_shell_secret
Loading
Loading
Loading
Loading
@@ -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"
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -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"
Loading
Loading
@@ -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 {
Loading
Loading
Loading
Loading
@@ -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"
)
Loading
Loading
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)
}
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())
}
})
}
}
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
}
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
}
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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment