From 18fe268adef726fec4b0a18f38e25b07454beb3f Mon Sep 17 00:00:00 2001 From: Florian Forestier Date: Fri, 18 Oct 2024 10:35:09 +0200 Subject: [PATCH] Added support for v1.18.0 --- README.md | 5 + patch.sh | 6 +- patches/v1.18.0.patch | 1538 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1548 insertions(+), 1 deletion(-) create mode 100644 patches/v1.18.0.patch diff --git a/README.md b/README.md index 4f088e9..9049a8c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Project which takes [HashiCorp Vault](https://github.com/hashicorp/vault/) sourc > Before migration to v1.15.6, don't forget to duplicate all your policies with the new formatted names. > After migration, you'll be able to remove old policies. +> **Please note that the GitLab connector is only available with a full-Vault build starting 1.18.0. + ## Building the image ### How it works @@ -35,6 +37,9 @@ To patch and ignore errors, you can run `git apply --reject --whitespace=fix ../ fix all files that were rejected. Run a `git add . && git commit -m "new version"` in `vault` directory, then a `git diff HEAD~1 > ../patches/v.patch` to save the full diff. +Starting v1.18.0, you can use the `patch.sh` command by adding a `--dev` argument at the end of the command. For +example: `./patch.sh v1.18.0 --dev`. + _____________ ## Using the connector (as an administrator) diff --git a/patch.sh b/patch.sh index 0054b40..88c1287 100755 --- a/patch.sh +++ b/patch.sh @@ -10,10 +10,14 @@ if [ ! -f "patches/$1.patch" ]; then exit 1 fi +ADDITIONAL_ARGS="" +if [ "$2" = "--dev" ]; then + ADDITIONAL_ARGS="--3way --whitespace=fix" +fi rm -rf vault/ > /dev/null 2>&1 git clone -b $1 https://github.com/hashicorp/vault.git vault cd ./vault -git apply ../patches/$1.patch +git apply $ADDITIONAL_ARGS ../patches/$1.patch echo "SUCCESS : If not error occurred, source have been patched !" diff --git a/patches/v1.18.0.patch b/patches/v1.18.0.patch new file mode 100644 index 0000000..7acb6a7 --- /dev/null +++ b/patches/v1.18.0.patch @@ -0,0 +1,1538 @@ +diff --git a/builtin/credential/gitlab/backend.go b/builtin/credential/gitlab/backend.go +new file mode 100644 +index 0000000000..77fed07c34 +--- /dev/null ++++ b/builtin/credential/gitlab/backend.go +@@ -0,0 +1,52 @@ ++package gitlab ++ ++import ( ++ "context" ++ "github.com/hashicorp/vault/sdk/framework" ++ "github.com/hashicorp/vault/sdk/logical" ++ mathrand "math/rand" ++) ++ ++const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" ++ ++// Factory of gitlab backend ++func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { ++ b := Backend() ++ if err := b.Setup(ctx, conf); err != nil { ++ return nil, err ++ } ++ b.CipherKey = make([]byte, 16) ++ for i := range b.CipherKey { ++ b.CipherKey[i] = letterBytes[mathrand.Intn(len(letterBytes))] ++ } ++ return b, nil ++} ++ ++// Backend constructor ++func Backend() *backend { ++ ++ var b backend ++ ++ b.Backend = &framework.Backend{ ++ Help: backendHelp, ++ PathsSpecial: &logical.Paths{Unauthenticated: []string{"login", "login/*", "oauth", "ci"}}, ++ Paths: append([]*framework.Path{pathConfig(&b), pathLoginToken(&b), pathOauthLogin(&b), pathLoginJob(&b)}), ++ BackendType: logical.TypeCredential, ++ } ++ ++ return &b ++} ++ ++type backend struct { ++ *framework.Backend ++ CipherKey []byte ++} ++ ++const backendHelp = ` ++The Gitlab credential provider allows authentication via Gitlab. ++ ++Users provide a personal access token to log in, and the credential ++provider maps the user to a set of Vault policies according to the groups he is part of. ++After enabling the credential provider, use the "config" route to ++configure it. ++` +diff --git a/builtin/credential/gitlab/backend_test.go b/builtin/credential/gitlab/backend_test.go +new file mode 100644 +index 0000000000..a93e106d27 +--- /dev/null ++++ b/builtin/credential/gitlab/backend_test.go +@@ -0,0 +1,164 @@ ++package gitlab ++ ++import ( ++ "context" ++ "os" ++ "strings" ++ "testing" ++ ++ logicaltest "github.com/hashicorp/vault/helper/testhelpers/logical" ++ "github.com/hashicorp/vault/sdk/logical" ++) ++ ++func TestBackend_Config(t *testing.T) { ++ b, err := Factory(context.Background(), &logical.BackendConfig{ ++ Logger: nil, ++ }) ++ if err != nil { ++ t.Fatalf("Unable to create backend: %s", err) ++ } ++ ++ loginData := map[string]interface{}{ ++ // This token has to be replaced with a working token for the test to work. ++ "token": os.Getenv("GITLAB_TOKEN"), ++ } ++ configData := map[string]interface{}{ ++ "group": os.Getenv("GITLAB_GROUP"), ++ } ++ ++ logicaltest.Test(t, logicaltest.TestCase{ ++ PreCheck: func() { testAccPreCheck(t) }, ++ LogicalBackend: b, ++ Steps: []logicaltest.TestStep{ ++ testConfigWrite(t, loginData), ++ testLoginWrite(t, configData, false), ++ }, ++ }) ++} ++ ++func testLoginWrite(t *testing.T, d map[string]interface{}, expectFail bool) logicaltest.TestStep { ++ return logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "login", ++ ErrorOk: true, ++ Data: d, ++ Check: func(resp *logical.Response) error { ++ if resp.IsError() && expectFail { ++ return nil ++ } ++ return nil ++ }, ++ } ++} ++ ++func testConfigWrite(t *testing.T, d map[string]interface{}) logicaltest.TestStep { ++ return logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "config", ++ Data: d, ++ } ++} ++ ++func TestBackend_basic(t *testing.T) { ++ b, err := Factory(context.Background(), &logical.BackendConfig{ ++ Logger: nil, ++ }) ++ if err != nil { ++ t.Fatalf("Unable to create backend: %s", err) ++ } ++ ++ logicaltest.Test(t, logicaltest.TestCase{ ++ PreCheck: func() { testAccPreCheck(t) }, ++ LogicalBackend: b, ++ Steps: []logicaltest.TestStep{ ++ testAccStepConfig(t, false), ++ testAccMap(t, "default", "fakepol"), ++ testAccMap(t, "oWnErs", "fakepol"), ++ testAccLogin(t, []string{"default", "fakepol"}), ++ testAccStepConfig(t, true), ++ testAccMap(t, "default", "fakepol"), ++ testAccMap(t, "oWnErs", "fakepol"), ++ testAccLogin(t, []string{"default", "fakepol"}), ++ testAccStepConfigWithBaseURL(t), ++ testAccMap(t, "default", "fakepol"), ++ testAccMap(t, "oWnErs", "fakepol"), ++ testAccLogin(t, []string{"default", "fakepol"}), ++ testAccMap(t, "default", "fakepol"), ++ testAccStepConfig(t, true), ++ mapUserToPolicy(t, os.Getenv("GITLAB_USER"), "userpolicy"), ++ testAccLogin(t, []string{"default", "fakepol", "userpolicy"}), ++ }, ++ }) ++} ++ ++func testAccPreCheck(t *testing.T) { ++ if v := os.Getenv("GITLAB_TOKEN"); v == "" { ++ t.Skip("GITLAB_TOKEN must be set for acceptance tests") ++ } ++ ++ if v := os.Getenv("GITLAB_GROUP"); v == "" { ++ t.Skip("GITLAB_GROUP must be set for acceptance tests") ++ } ++ ++ if v := os.Getenv("GITLAB_BASEURL"); v == "" { ++ t.Skip("GITLAB_BASEURL must be set for acceptance tests (use 'https://gitlab.com/api/v4/' if you don't know what you're doing)") ++ } ++} ++ ++func testAccStepConfig(t *testing.T, upper bool) logicaltest.TestStep { ++ ts := logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "config", ++ Data: map[string]interface{}{ ++ "organization": os.Getenv("GITLAB_GROUP"), ++ }, ++ } ++ if upper { ++ ts.Data["organization"] = strings.ToUpper(os.Getenv("GITLAB_GROUP")) ++ } ++ return ts ++} ++ ++func testAccStepConfigWithBaseURL(t *testing.T) logicaltest.TestStep { ++ return logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "config", ++ Data: map[string]interface{}{ ++ "organization": os.Getenv("GITLAB_GROUP"), ++ "base_url": os.Getenv("GITLAB_BASEURL"), ++ }, ++ } ++} ++ ++func testAccMap(t *testing.T, k string, v string) logicaltest.TestStep { ++ return logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "map/teams/" + k, ++ Data: map[string]interface{}{ ++ "value": v, ++ }, ++ } ++} ++ ++func mapUserToPolicy(t *testing.T, k string, v string) logicaltest.TestStep { ++ return logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "map/users/" + k, ++ Data: map[string]interface{}{ ++ "value": v, ++ }, ++ } ++} ++ ++func testAccLogin(t *testing.T, policies []string) logicaltest.TestStep { ++ return logicaltest.TestStep{ ++ Operation: logical.UpdateOperation, ++ Path: "login", ++ Data: map[string]interface{}{ ++ "token": os.Getenv("GITLAB_TOKEN"), ++ }, ++ Unauthenticated: true, ++ ++ Check: logicaltest.TestCheckAuth(policies), ++ } ++} +diff --git a/builtin/credential/gitlab/cli.go b/builtin/credential/gitlab/cli.go +new file mode 100644 +index 0000000000..705f7a16dd +--- /dev/null ++++ b/builtin/credential/gitlab/cli.go +@@ -0,0 +1,98 @@ ++package gitlab ++ ++import ( ++"fmt" ++"io" ++"os" ++"strings" ++ ++"github.com/hashicorp/errwrap" ++"github.com/hashicorp/vault/api" ++"github.com/hashicorp/vault/sdk/helper/password" ++) ++ ++// CLIHandler structure ++type CLIHandler struct { ++ // for tests ++ testStdout io.Writer ++} ++ ++// Auth return secret token ++func (h *CLIHandler) Auth(c *api.Client, m map[string]string) (*api.Secret, error) { ++ mount, ok := m["mount"] ++ if !ok { ++ mount = "gitlab" ++ } ++ ++ // Extract or prompt for token ++ token := m["token"] ++ if token == "" { ++ token = os.Getenv("VAULT_AUTH_GITLAB_TOKEN") ++ } ++ if token == "" { ++ // Override the output ++ stdout := h.testStdout ++ if stdout == nil { ++ stdout = os.Stderr ++ } ++ ++ var err error ++ fmt.Fprintf(stdout, "Gitlab Access Token (will be hidden): ") ++ token, err = password.Read(os.Stdin) ++ fmt.Fprintf(stdout, "\n") ++ if err != nil { ++ if err == password.ErrInterrupted { ++ return nil, fmt.Errorf("user interrupted") ++ } ++ ++ return nil, errwrap.Wrapf("An error occurred attempting to "+ ++ "ask for a token. The raw error message is shown below, but usually "+ ++ "this is because you attempted to pipe a value into the command or "+ ++ "you are executing outside of a terminal (tty). If you want to pipe "+ ++ "the value, pass \"-\" as the argument to read from stdin. The raw "+ ++ "error was: {{err}}", err) ++ } ++ } ++ ++ path := fmt.Sprintf("auth/%s/login", mount) ++ secret, err := c.Logical().Write(path, map[string]interface{}{ ++ "token": strings.TrimSpace(token), ++ }) ++ if err != nil { ++ return nil, err ++ } ++ if secret == nil { ++ return nil, fmt.Errorf("empty response from credential provider") ++ } ++ ++ return secret, nil ++} ++ ++// Help return help message ++func (h *CLIHandler) Help() string { ++ help := ` ++Usage: vault login -method=gitlab [CONFIG K=V...] ++ ++ The Gitlab auth method allows users to authenticate using a Gitlab ++ access token. Users can generate a personal access token from the ++ settings page on their Gitlab account. ++ ++ Authenticate using a Gitlab token: ++ ++ $ vault login -method=gitlab token=abcd1234 ++ ++Configuration: ++ ++ mount= ++ Path where the Gitlab credential method is mounted. This is usually ++ provided via the -path flag in the "vault login" command, but it can be ++ specified here as well. If specified here, it takes precedence over the ++ value for -path. The default value is "gitlab". ++ ++ token= ++ Gitlab access token to use for authentication. If not provided, ++ Vault will prompt for the value. ++` ++ ++ return strings.TrimSpace(help) ++} +diff --git a/builtin/credential/gitlab/clients.go b/builtin/credential/gitlab/clients.go +new file mode 100644 +index 0000000000..c3212de492 +--- /dev/null ++++ b/builtin/credential/gitlab/clients.go +@@ -0,0 +1,40 @@ ++package gitlab ++ ++import ( ++ "errors" ++ "github.com/xanzy/go-gitlab" ++ "strconv" ++ "strings" ++) ++ ++func (b *backend) TokenClient(baseUrl string, token string) (*gitlab.Client, error) { ++ if strings.HasPrefix(token, "OAuth-") { ++ return gitlab.NewOAuthClient(strings.TrimPrefix(token, "OAuth-"), gitlab.WithBaseURL(baseUrl)) ++ } ++ return gitlab.NewClient(token, gitlab.WithBaseURL(baseUrl)) ++} ++ ++// -------------------------------- ++ ++func (b *backend) JobClient(baseURL, CIToken, project, job, commit, token string) (*gitlab.Client, error) { ++ client, err := gitlab.NewClient(CIToken, gitlab.WithBaseURL(baseURL)) ++ if err != nil { ++ return nil, err ++ } ++ ++ jobID, err := strconv.Atoi(job) ++ if err != nil { ++ return nil, err ++ } ++ ++ j, _, err := client.Jobs.GetJob(project, jobID) ++ if err != nil { ++ return nil, err ++ } ++ ++ if j.Status != string(gitlab.Running) || j.Commit.ID != commit { ++ return nil, errors.New("invalid job arguments") ++ } ++ ++ return client, nil ++} +diff --git a/builtin/credential/gitlab/cmd/gitlab/main.go b/builtin/credential/gitlab/cmd/gitlab/main.go +new file mode 100644 +index 0000000000..3e2b4b4ca5 +--- /dev/null ++++ b/builtin/credential/gitlab/cmd/gitlab/main.go +@@ -0,0 +1,29 @@ ++package main ++ ++import ( ++ "github.com/hashicorp/vault/api" ++ "github.com/hashicorp/vault/builtin/credential/gitlab" ++ "os" ++ ++ hclog "github.com/hashicorp/go-hclog" ++ "github.com/hashicorp/vault/sdk/plugin" ++) ++ ++func main() { ++ apiClientMeta := &api.PluginAPIClientMeta{} ++ flags := apiClientMeta.FlagSet() ++ flags.Parse(os.Args[1:]) ++ ++ tlsConfig := apiClientMeta.GetTLSConfig() ++ tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig) ++ ++ if err := plugin.Serve(&plugin.ServeOpts{ ++ BackendFactoryFunc: gitlab.Factory, ++ TLSProviderFunc: tlsProviderFunc, ++ }); err != nil { ++ logger := hclog.New(&hclog.LoggerOptions{}) ++ ++ logger.Error("plugin shutting down", "error", err) ++ os.Exit(1) ++ } ++} +diff --git a/builtin/credential/gitlab/path_config.go b/builtin/credential/gitlab/path_config.go +new file mode 100644 +index 0000000000..23c5f25256 +--- /dev/null ++++ b/builtin/credential/gitlab/path_config.go +@@ -0,0 +1,194 @@ ++package gitlab ++ ++import ( ++ "context" ++ "fmt" ++ "github.com/hashicorp/errwrap" ++ "github.com/hashicorp/vault/sdk/framework" ++ "github.com/hashicorp/vault/sdk/logical" ++ "github.com/xanzy/go-gitlab" ++ "net/http" ++ "net/url" ++) ++ ++func pathConfig(b *backend) *framework.Path { ++ return &framework.Path{ ++ Pattern: "config", ++ Fields: map[string]*framework.FieldSchema{ ++ "base_url": { ++ Type: framework.TypeString, ++ Description: "The Gitlab API endpoint to use.", ++ }, ++ "min_access_level": { ++ Type: framework.TypeString, ++ Description: "The minimal project access level that users must have", ++ Default: "guest", ++ }, ++ "app_id": { ++ Type: framework.TypeString, ++ Description: "The OAuth appId", ++ Default: "", ++ }, ++ "app_secret": { ++ Type: framework.TypeString, ++ Description: "The OAuth appSecret", ++ Default: "", ++ }, ++ "callback_url": { ++ Type: framework.TypeString, ++ Description: "The Vault OAuth API endpoint to use.", ++ Default: "", ++ }, ++ "ci_token": { ++ Type: framework.TypeString, ++ Description: "The CI token API to use.", ++ Default: "", ++ }, ++ }, ++ ++ Operations: map[logical.Operation]framework.OperationHandler{ ++ logical.UpdateOperation: &framework.PathOperation{ ++ Callback: b.pathConfigWrite, ++ Responses: map[int][]framework.Response{ ++ http.StatusOK: {{ ++ Description: http.StatusText(http.StatusOK), ++ }}, ++ }, ++ }, ++ logical.ReadOperation: &framework.PathOperation{ ++ Callback: b.pathConfigRead, ++ Responses: map[int][]framework.Response{ ++ http.StatusOK: {{ ++ Description: http.StatusText(http.StatusOK), ++ }}, ++ }, ++ }, ++ }, ++ } ++} ++ ++func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { ++ baseURL := data.Get("base_url").(string) ++ if len(baseURL) > 0 { ++ _, err := url.Parse(baseURL) ++ if err != nil { ++ return logical.ErrorResponse(fmt.Sprintf("Error parsing given base_url: %s", err)), nil ++ } ++ } ++ minAccessLevel := data.Get("min_access_level").(string) ++ appID := data.Get("app_id").(string) ++ appSecret := data.Get("app_secret").(string) ++ callbackURL := data.Get("callback_url").(string) ++ ciToken := data.Get("ci_token").(string) ++ if len(callbackURL) > 0 { ++ _, err := url.Parse(callbackURL) ++ if err != nil { ++ return logical.ErrorResponse(fmt.Sprintf("Error parsing given callback_url: %s", err)), nil ++ } ++ } ++ entry, err := logical.StorageEntryJSON("config", config{ ++ BaseURL: baseURL, ++ MinAccessLevel: minAccessLevel, ++ AppID: appID, ++ AppSecret: appSecret, ++ CallbackURL: callbackURL, ++ CIToken: ciToken, ++ }) ++ ++ if err != nil { ++ return nil, err ++ } ++ ++ if err := req.Storage.Put(ctx, entry); err != nil { ++ return nil, err ++ } ++ ++ return nil, nil ++} ++ ++func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { ++ config, err := b.Config(ctx, req.Storage) ++ if err != nil { ++ return nil, err ++ } ++ ++ if config == nil { ++ return nil, fmt.Errorf("configuration object not found") ++ } ++ ++ resp := &logical.Response{ ++ Data: map[string]interface{}{ ++ "base_url": config.BaseURL, ++ "min_access_level": config.MinAccessLevel, ++ "app_id": config.AppID, ++ "app_secret": config.AppSecret, ++ "callback_url": config.CallbackURL, ++ "ci_token": config.CIToken, ++ }, ++ } ++ return resp, nil ++} ++ ++// Config returns the configuration for this backend. ++func (b *backend) Config(ctx context.Context, s logical.Storage) (*config, error) { ++ entry, err := s.Get(ctx, "config") ++ if err != nil { ++ return nil, err ++ } ++ ++ var result config ++ if entry != nil { ++ if err := entry.DecodeJSON(&result); err != nil { ++ return nil, errwrap.Wrapf("error reading configuration: {{err}}", err) ++ } ++ } ++ ++ return &result, nil ++} ++ ++func (b *backend) AccessLevelValue(level string) *gitlab.AccessLevelValue { ++ if level == "" { ++ return gitlab.AccessLevel(gitlab.OwnerPermission) ++ } ++ return gitlab.AccessLevel(accessLevelNameToValue[level]) ++} ++ ++// AllAccessValuesFromLevel Meaning for a owner access, we also have access to developer, guest, etc. ++func (b *backend) AllAccessValuesFromLevel(level string) []string { ++ var accessLevelValues []string ++ ++ var start = int(*b.AccessLevelValue(level)) ++ for k, v := range accessLevelNameToValue { ++ if int(v) <= start && v != 0 { // not adding "none" level access ++ accessLevelValues = append(accessLevelValues, k) ++ } ++ } ++ return accessLevelValues ++} ++ ++func (b *backend) AccessLevelValueToString(level gitlab.AccessLevelValue) string { ++ for k, v := range accessLevelNameToValue { ++ if v == level { ++ return k ++ } ++ } ++ return "none" ++} ++ ++var accessLevelNameToValue = map[string]gitlab.AccessLevelValue{ ++ "none": gitlab.NoPermissions, ++ "guest": gitlab.GuestPermissions, ++ "reporter": gitlab.ReporterPermissions, ++ "developer": gitlab.DeveloperPermissions, ++ "maintainer": gitlab.MaintainerPermissions, ++ "owner": gitlab.OwnerPermissions, ++} ++ ++type config struct { ++ BaseURL string `json:"baseURL" structs:"baseURL" mapstructure:"baseURL"` ++ MinAccessLevel string `json:"minAccessLevel" structs:"minAccessLevel" mapstructure:"minAccessLevel"` ++ AppID string `json:"appID" structs:"appID" mapstructure:"appID"` ++ AppSecret string `json:"appSecret" structs:"appSecret" mapstructure:"appSecret"` ++ CallbackURL string `json:"callbackURL" structs:"callbackURL" mapstructure:"callbackURL"` ++ CIToken string `json:"ciToken" structs:"ciToken" mapstructure:"ciToken"` ++} +diff --git a/builtin/credential/gitlab/path_login_commons.go b/builtin/credential/gitlab/path_login_commons.go +new file mode 100644 +index 0000000000..659205afd6 +--- /dev/null ++++ b/builtin/credential/gitlab/path_login_commons.go +@@ -0,0 +1,185 @@ ++package gitlab ++ ++import ( ++ "context" ++ "fmt" ++ "github.com/hashicorp/vault/sdk/logical" ++ "github.com/hasura/go-graphql-client" ++ "github.com/xanzy/go-gitlab" ++ "net/http" ++ "strings" ++) ++ ++func (b *backend) pathLoginOk(verifyResp *verifyCredentialsResp, internalData map[string]interface{}) *logical.Response { ++ resp := &logical.Response{ ++ Auth: &logical.Auth{ ++ InternalData: internalData, ++ Metadata: map[string]string{ ++ "username": verifyResp.Username, ++ }, ++ DisplayName: verifyResp.Username, ++ LeaseOptions: logical.LeaseOptions{ ++ Renewable: false, ++ }, ++ Alias: &logical.Alias{ ++ Name: verifyResp.Username, ++ }, ++ EntityID: verifyResp.Username, ++ }, ++ } ++ ++ if verifyResp.IsAdmin { ++ if b.Logger().IsDebug() { ++ b.Logger().Debug("User " + verifyResp.Username + " is admin") ++ } ++ resp.Auth.Policies = append(resp.Auth.Policies, "admins") ++ } ++ ++ for _, name := range verifyResp.Rights { ++ resp.Auth.GroupAliases = append(resp.Auth.GroupAliases, &logical.Alias{Name: name}) ++ resp.Auth.Policies = append(resp.Auth.Policies, name) ++ } ++ ++ return resp ++} ++ ++func (b *backend) parseAdminRights(username string, isAdmin bool, access []string) (*verifyCredentialsResp, error) { ++ if isAdmin { ++ if b.Logger().IsDebug() { ++ b.Logger().Debug("User " + username + " is admin") ++ } ++ access = append(access, "admins") ++ } ++ ++ return &verifyCredentialsResp{ ++ Username: username, ++ Rights: access, ++ IsAdmin: isAdmin, ++ }, nil ++} ++ ++func (b *backend) getProjectsAndGroupsAccess(ctx context.Context, req *logical.Request, client *gitlab.Client, userToken string) (access []string, err error) { ++ config, err := b.Config(ctx, req.Storage) ++ if err != nil { ++ return nil, err ++ } ++ var noneAccessLevels []string ++ minAccessLevel := int(accessLevelNameToValue[config.MinAccessLevel]) ++ lastCursorProjects := "" ++ nextPageProjects := true ++ lastCursorGroups := "" ++ nextPageGroups := true ++ graphQLClient := graphql.NewClient(fmt.Sprintf("%s://%s/api/graphql", client.BaseURL().Scheme, client.BaseURL().Host), http.DefaultClient).WithRequestModifier(func(r *http.Request) { ++ r.Header.Set("Authorization", "Bearer "+userToken) ++ }) ++ ++ for nextPageProjects || nextPageGroups { ++ var query graphQLProjectsGroups ++ variables := map[string]interface{}{ ++ "afterProjects": lastCursorProjects, ++ "afterGroups": lastCursorGroups, ++ } ++ err := graphQLClient.Query(context.Background(), &query, variables) ++ if err != nil { ++ b.Logger().Error("Something went wrong while getting query " + err.Error()) ++ break ++ } ++ ++ // parse GraphQL groups and projects response ++ if nextPageProjects { ++ var tmpMaxRights []string ++ ++ nextPageProjects = query.Projects.PageInfo.HasNextPage ++ tmpMaxRights, _, lastCursorProjects = b.parseGraphQLEdgesToMaxRights(query.Projects.Edges, minAccessLevel) ++ access = append(access, tmpMaxRights...) ++ } ++ // we can only get group direct membership, so we keep "none" access groups ++ if nextPageGroups { ++ var tmpMaxRights, tmpNone []string ++ ++ nextPageGroups = query.Groups.PageInfo.HasNextPage ++ tmpMaxRights, tmpNone, lastCursorGroups = b.parseGraphQLEdgesToMaxRights(query.Groups.Edges, minAccessLevel) ++ access = append(access, tmpMaxRights...) ++ noneAccessLevels = append(noneAccessLevels, tmpNone...) ++ } ++ ++ } ++ // parse "none" access groups to their MaxAccess according to root groups ++ access = append(access, b.parseNoneAccessLevelsToMaxRights(access, noneAccessLevels)...) ++ // parse all rights access inheritance (E.g: owner = owner + maintainer + dev + guest) ++ access = b.parseMaxRightsToRights(access) ++ return access, nil ++} ++ ++// parseGraphQLEdgesToMaxRights parses GraphQL results to MaxAccess rights, also returns "none" access levels ++func (b *backend) parseGraphQLEdgesToMaxRights(edges []graphQLEdge, minAccessLevel int) (rights []string, nonAccessLevels []string, lastCursor string) { ++ for _, e := range edges { ++ accessLevelInt := int(e.Node.MaxAccessLevel.IntegerValue) ++ if accessLevelInt >= minAccessLevel && accessLevelInt > 0 { ++ rights = append(rights, fmt.Sprintf("%s:%s", strings.ReplaceAll(strings.ReplaceAll(e.Node.FullPath, "/", ":"), "-", "_"), b.AccessLevelValueToString(e.Node.MaxAccessLevel.IntegerValue))) ++ } ++ if accessLevelInt == 0 { ++ nonAccessLevels = append(nonAccessLevels, strings.ReplaceAll(strings.ReplaceAll(e.Node.FullPath, "/", ":"), "-", "_")) ++ } ++ lastCursor = e.Cursor ++ } ++ return ++} ++ ++// parseNoneAccessLevelsToMaxRights parses "none" access level rights to MaxAccess rights if user has inherited rights ++func (b *backend) parseNoneAccessLevelsToMaxRights(access, noneAccess []string) (rights []string) { ++ for _, value := range noneAccess { ++ for _, aValue := range access { ++ splitAValue := strings.Split(aValue, ":") ++ aValueWithoutAccess, _ := strings.CutSuffix(aValue, ":"+splitAValue[len(splitAValue)-1]) // remove last :access ++ if strings.HasPrefix(value, aValueWithoutAccess) { ++ rights = append(rights, fmt.Sprintf("%s:%s", value, splitAValue[len(splitAValue)-1])) ++ break // only has one per group / project ++ } ++ } ++ } ++ return ++} ++ ++// parseMaxRightsToRights parses MaxAccess rights to all rights ++func (b *backend) parseMaxRightsToRights(maxRights []string) (rights []string) { ++ for _, r := range maxRights { ++ rSplit := strings.Split(r, ":") ++ rWithoutAccess, _ := strings.CutSuffix(r, ":"+rSplit[len(rSplit)-1]) // remove last :access ++ for _, level := range b.AllAccessValuesFromLevel(rSplit[len(rSplit)-1]) { ++ rights = append(rights, fmt.Sprintf("%s:%s", rWithoutAccess, level)) ++ } ++ } ++ return ++} ++ ++type verifyCredentialsResp struct { ++ Username string ++ Rights []string ++ IsAdmin bool ++} ++ ++type graphQLProjectsGroups struct { ++ Projects struct { ++ PageInfo struct { ++ HasNextPage bool `json:"hasNextPage"` ++ } `json:"pageInfo"` ++ Edges []graphQLEdge ++ } `graphql:"projects(membership:true,after:$afterProjects)"` ++ Groups struct { ++ PageInfo struct { ++ HasNextPage bool `json:"hasNextPage"` ++ } `json:"pageInfo"` ++ Edges []graphQLEdge ++ } `graphql:"groups(after:$afterGroups)"` ++} ++ ++type graphQLEdge struct { ++ Cursor string ++ Node struct { ++ FullPath string `json:"fullPath"` ++ MaxAccessLevel struct { ++ IntegerValue gitlab.AccessLevelValue `json:"integerValue"` ++ } `json:"maxAccessLevel"` ++ } ++} +diff --git a/builtin/credential/gitlab/path_login_job.go b/builtin/credential/gitlab/path_login_job.go +new file mode 100644 +index 0000000000..16727802ea +--- /dev/null ++++ b/builtin/credential/gitlab/path_login_job.go +@@ -0,0 +1,139 @@ ++package gitlab ++ ++import ( ++ "context" ++ "encoding/json" ++ "errors" ++ "fmt" ++ "github.com/hashicorp/go-cleanhttp" ++ "github.com/hashicorp/vault/sdk/framework" ++ "github.com/hashicorp/vault/sdk/logical" ++ "github.com/xanzy/go-gitlab" ++ "io" ++ "net/http" ++ "net/url" ++ "time" ++) ++ ++func pathLoginJob(b *backend) *framework.Path { ++ return &framework.Path{ ++ Pattern: `ci`, ++ Fields: map[string]*framework.FieldSchema{ ++ "token": {Type: framework.TypeString, Description: "Gitlab Job token"}, ++ }, ++ Operations: map[logical.Operation]framework.OperationHandler{ ++ logical.UpdateOperation: &framework.PathOperation{ ++ Callback: b.pathLoginByJob, ++ Responses: map[int][]framework.Response{ ++ http.StatusOK: {{ ++ Description: http.StatusText(http.StatusOK), ++ }}, ++ }, ++ }, ++ }, ++ } ++} ++ ++func (b *backend) pathLoginByJob(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { ++ ++ config, err := b.Config(ctx, req.Storage) ++ if err != nil { ++ return nil, err ++ } ++ ++ if config.CIToken == "" { ++ return nil, fmt.Errorf("config CI access disabled") ++ } ++ ++ // Get Job ++ job, err := b.getJobByToken(config, data.Get("token").(string)) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Admin client ++ client, err := b.TokenClient(config.BaseURL, config.CIToken) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Generate impersonation token for this user ++ name := "vault-connexion" ++ scopes := []string{"read_api"} ++ expTime := time.Now().Add(time.Hour * 24) ++ token, _, err := client.Users.CreateImpersonationToken(job.User.ID, &gitlab.CreateImpersonationTokenOptions{ ++ Name: &name, ++ Scopes: &scopes, ++ ExpiresAt: &expTime, ++ }) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Get rights ++ userClient, err := b.TokenClient(config.BaseURL, token.Token) ++ if err != nil { ++ return nil, err ++ } ++ ++ user, _, err := userClient.Users.CurrentUser() ++ if err != nil { ++ return nil, err ++ } ++ ++ access, err := b.getProjectsAndGroupsAccess(ctx, req, userClient, token.Token) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Revoke impersonation ++ _, err = client.Users.RevokeImpersonationToken(job.User.ID, token.ID) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Finalize connection ++ if verifyResponse, err := b.parseAdminRights(user.Username, user.IsAdmin, access); err != nil { ++ return nil, err ++ } else { ++ return b.pathLoginOk(verifyResponse, map[string]interface{}{"token": data.Get("token").(string)}), nil ++ } ++} ++ ++func (b *backend) getJobByToken(config *config, jobToken string) (job gitlab.Job, err error) { ++ // Verify that job is running, and user id. ++ u, err := url.Parse(fmt.Sprintf("%s/api/v4/job", config.BaseURL)) ++ if err != nil { ++ return ++ } ++ ++ headers := make(http.Header) ++ headers.Add("JOB-TOKEN", jobToken) ++ ++ resp, err := cleanhttp.DefaultClient().Do(&http.Request{ ++ Method: "GET", ++ URL: u, ++ Proto: "HTTP/1.1", ++ ProtoMajor: 1, ++ ProtoMinor: 1, ++ Header: headers, ++ Host: u.Host, ++ }) ++ if err != nil { ++ return ++ } ++ if resp.StatusCode != http.StatusOK { ++ err = errors.New(resp.Status) ++ return ++ } ++ ++ data2, _ := io.ReadAll(resp.Body) ++ _ = json.Unmarshal(data2, &job) ++ ++ if job.Status != "running" { ++ err = errors.New("job is not running anymore ; could not generate token") ++ return ++ } ++ ++ return ++} +diff --git a/builtin/credential/gitlab/path_login_oauth.go b/builtin/credential/gitlab/path_login_oauth.go +new file mode 100644 +index 0000000000..262d0f7872 +--- /dev/null ++++ b/builtin/credential/gitlab/path_login_oauth.go +@@ -0,0 +1,209 @@ ++package gitlab ++ ++import ( ++ "context" ++ "crypto/aes" ++ "crypto/cipher" ++ "crypto/rand" ++ "encoding/base64" ++ "errors" ++ "fmt" ++ "github.com/hashicorp/vault/sdk/framework" ++ "github.com/hashicorp/vault/sdk/logical" ++ "github.com/xanzy/go-gitlab" ++ "golang.org/x/oauth2" ++ "io" ++ "net/http" ++ "net/url" ++ "strconv" ++ "time" ++) ++ ++func pathOauthLogin(b *backend) *framework.Path { ++ return &framework.Path{ ++ Pattern: `oauth`, ++ Fields: map[string]*framework.FieldSchema{ ++ "code": {Type: framework.TypeString, Description: "Gitlab API code"}, ++ "state": {Type: framework.TypeString, Description: "Gitlab API state", Default: ""}, ++ }, ++ ++ Operations: map[logical.Operation]framework.OperationHandler{ ++ logical.UpdateOperation: &framework.PathOperation{ ++ Callback: b.pathOauthLogin, ++ Responses: map[int][]framework.Response{ ++ http.StatusOK: {{ ++ Description: http.StatusText(http.StatusOK), ++ }}, ++ }, ++ }, ++ logical.ReadOperation: &framework.PathOperation{ ++ Callback: b.pathOauthLogin, ++ Responses: map[int][]framework.Response{ ++ http.StatusOK: {{ ++ Description: http.StatusText(http.StatusOK), ++ }}, ++ }, ++ }, ++ }, ++ } ++} ++ ++func (b *backend) pathOauthLogin(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { ++ config, err := b.Config(ctx, req.Storage) ++ if err != nil { ++ return nil, err ++ } ++ ++ if config.AppSecret == "" || config.AppID == "" || config.CallbackURL == "" { ++ return nil, fmt.Errorf("config OAuth disabled") ++ } ++ ++ baseURL, _ := url.Parse(config.BaseURL) ++ callbackURL, _ := url.Parse(config.CallbackURL) ++ ++ oauth2Conf := &oauth2.Config{ ++ ClientID: config.AppID, ++ ClientSecret: config.AppSecret, ++ Endpoint: oauth2.Endpoint{ ++ AuthURL: fmt.Sprintf("%s://%s/oauth/authorize", baseURL.Scheme, baseURL.Host), ++ TokenURL: fmt.Sprintf("%s://%s/oauth/token", baseURL.Scheme, baseURL.Host), ++ }, ++ Scopes: []string{"api", "read_user"}, ++ RedirectURL: fmt.Sprintf("%s://%s/v1/%s%s", callbackURL.Scheme, callbackURL.Host, req.MountPoint, req.Path), ++ } ++ ++ code, _ := data.GetOk("code") ++ ++ if code != nil { ++ state := data.Get("state") ++ err = b.CheckState(state.(string)) ++ if err != nil { ++ return nil, err ++ } ++ ++ token, err := oauth2Conf.Exchange(ctx, code.(string)) ++ if err != nil { ++ return nil, err ++ } ++ client, err := b.TokenClient(config.BaseURL, "OAuth-"+token.AccessToken) ++ if err != nil { ++ return nil, err ++ } ++ ++ user, _, err := client.Users.CurrentUser() ++ if err != nil { ++ return nil, err ++ } ++ ++ // Admin client ++ adminClient, err := b.TokenClient(config.BaseURL, config.CIToken) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Generate impersonation token for this user ++ name := "vault-connexion" ++ scopes := []string{"read_api"} ++ expTime := time.Now().Add(time.Hour * 24) ++ userToken, _, err := adminClient.Users.CreateImpersonationToken(user.ID, &gitlab.CreateImpersonationTokenOptions{ ++ Name: &name, ++ Scopes: &scopes, ++ ExpiresAt: &expTime, ++ }) ++ if err != nil { ++ return nil, err ++ } ++ ++ access, err := b.getProjectsAndGroupsAccess(ctx, req, client, userToken.Token) ++ if err != nil { ++ b.Logger().Error("ERROR : ", err.Error()) ++ return nil, err ++ } ++ ++ // Revoke impersonation ++ _, err = adminClient.Users.RevokeImpersonationToken(user.ID, userToken.ID) ++ if err != nil { ++ return nil, err ++ } ++ ++ if verifyResponse, err := b.parseAdminRights(user.Username, user.IsAdmin, access); err != nil { ++ return nil, err ++ } else { ++ response := b.pathLoginOk(verifyResponse, map[string]interface{}{ ++ "token": token.AccessToken, ++ }) ++ wrappedResponse, err := b.System().ResponseWrapData(ctx, map[string]interface{}{ ++ "authType": "gitlab", ++ "token": "OAuth-" + token.AccessToken, ++ }, time.Second*60, false) ++ if err != nil { ++ return nil, err ++ } ++ response.Redirect = "/ui/vault/auth?with=gitlab&wrapped_token=" + wrappedResponse.Token ++ return response, nil ++ } ++ } else { ++ state, err := b.State() ++ if err != nil { ++ return nil, err ++ } ++ return &logical.Response{ ++ Redirect: oauth2Conf.AuthCodeURL(state, oauth2.AccessTypeOffline), ++ }, nil ++ } ++} ++ ++func (b *backend) State() (encoded string, err error) { ++ plainText := []byte(strconv.FormatInt(time.Now().UnixNano(), 10)) ++ ++ block, err := aes.NewCipher(b.CipherKey) ++ if err != nil { ++ return ++ } ++ ++ //IV needs to be unique, but doesn't have to be secure. ++ //It's common to put it at the beginning of the ciphertext. ++ cipherText := make([]byte, aes.BlockSize+len(plainText)) ++ iv := cipherText[:aes.BlockSize] ++ if _, err = io.ReadFull(rand.Reader, iv); err != nil { ++ return ++ } ++ ++ stream := cipher.NewCFBEncrypter(block, iv) ++ stream.XORKeyStream(cipherText[aes.BlockSize:], plainText) ++ ++ //returns to base64 encoded string ++ encoded = base64.URLEncoding.EncodeToString(cipherText) ++ return ++} ++ ++func (b *backend) CheckState(secureState string) (err error) { ++ now := time.Now().UnixNano() ++ cipherText, err := base64.URLEncoding.DecodeString(secureState) ++ if err != nil { ++ return ++ } ++ ++ block, err := aes.NewCipher(b.CipherKey) ++ if err != nil { ++ return ++ } ++ ++ if len(cipherText) < aes.BlockSize { ++ err = errors.New("illegal State") ++ return ++ } ++ ++ iv := cipherText[:aes.BlockSize] ++ cipherText = cipherText[aes.BlockSize:] ++ ++ stream := cipher.NewCFBDecrypter(block, iv) ++ // XORKeyStream can work in-place if the two arguments are the same. ++ stream.XORKeyStream(cipherText, cipherText) ++ ++ decoded, err := strconv.ParseInt(string(cipherText), 10, 64) ++ if err == nil && (decoded > now || now-decoded > 60*int64(time.Second)) { ++ err = errors.New("illegal State") ++ } ++ return ++} +diff --git a/builtin/credential/gitlab/path_login_tokn.go b/builtin/credential/gitlab/path_login_tokn.go +new file mode 100644 +index 0000000000..b7c24d82be +--- /dev/null ++++ b/builtin/credential/gitlab/path_login_tokn.go +@@ -0,0 +1,85 @@ ++package gitlab ++ ++import ( ++ "context" ++ "github.com/hashicorp/vault/sdk/framework" ++ "github.com/hashicorp/vault/sdk/logical" ++ "github.com/xanzy/go-gitlab" ++ "net/http" ++ "time" ++) ++ ++func pathLoginToken(b *backend) *framework.Path { ++ return &framework.Path{ ++ Pattern: `login`, ++ Fields: map[string]*framework.FieldSchema{ ++ "token": {Type: framework.TypeString, Description: "Gitlab API token"}, ++ }, ++ ++ Operations: map[logical.Operation]framework.OperationHandler{ ++ logical.UpdateOperation: &framework.PathOperation{ ++ Callback: b.pathLoginByToken, ++ Responses: map[int][]framework.Response{ ++ http.StatusOK: {{ ++ Description: http.StatusText(http.StatusOK), ++ }}, ++ }, ++ }, ++ }, ++ } ++} ++ ++func (b *backend) pathLoginByToken(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { ++ config, err := b.Config(ctx, req.Storage) ++ if err != nil { ++ return nil, err ++ } ++ ++ client, err := b.TokenClient(config.BaseURL, data.Get("token").(string)) ++ if err != nil { ++ return nil, err ++ } ++ ++ user, _, err := client.Users.CurrentUser() ++ if err != nil { ++ return nil, err ++ } ++ ++ // Admin client ++ adminClient, err := b.TokenClient(config.BaseURL, config.CIToken) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Generate impersonation token for this user ++ name := "vault-connexion" ++ scopes := []string{"read_api"} ++ expTime := time.Now().Add(time.Hour * 24) ++ userToken, _, err := adminClient.Users.CreateImpersonationToken(user.ID, &gitlab.CreateImpersonationTokenOptions{ ++ Name: &name, ++ Scopes: &scopes, ++ ExpiresAt: &expTime, ++ }) ++ if err != nil { ++ return nil, err ++ } ++ ++ access, err := b.getProjectsAndGroupsAccess(ctx, req, client, userToken.Token) ++ if err != nil { ++ return nil, err ++ } ++ ++ // Revoke impersonation ++ _, err = adminClient.Users.RevokeImpersonationToken(user.ID, userToken.ID) ++ if err != nil { ++ return nil, err ++ } ++ ++ if verifyResponse, err := b.parseAdminRights(user.Username, user.IsAdmin, access); err != nil { ++ return nil, err ++ } else { ++ return b.pathLoginOk(verifyResponse, map[string]interface{}{ ++ "token": data.Get("token").(string), ++ }), nil ++ } ++} +diff --git a/command/base_predict.go b/command/base_predict.go +index ed3edfa30f..70fe7e73d2 100644 +--- a/command/base_predict.go ++++ b/command/base_predict.go +@@ -111,6 +111,7 @@ func (b *BaseCommand) PredictVaultAvailableAuths() complete.Predictor { + "cert", + "gcp", + "github", ++ "gitlab", + "ldap", + "okta", + "plugin", +diff --git a/command/base_predict_test.go b/command/base_predict_test.go +index 387b8f0b84..477eda9c5f 100644 +--- a/command/base_predict_test.go ++++ b/command/base_predict_test.go +@@ -359,6 +359,7 @@ func TestPredict_Plugins(t *testing.T) { + "gcp", + "gcpkms", + "github", ++ "gitlab", + "hana-database-plugin", + "influxdb-database-plugin", + "jwt", +diff --git a/command/commands_full.go b/command/commands_full.go +index 8db22350cf..18bb2845b9 100644 +--- a/command/commands_full.go ++++ b/command/commands_full.go +@@ -15,6 +15,7 @@ import ( + credOCI "github.com/hashicorp/vault-plugin-auth-oci" + credAws "github.com/hashicorp/vault/builtin/credential/aws" + credGitHub "github.com/hashicorp/vault/builtin/credential/github" ++ credGitlab "github.com/hashicorp/vault/builtin/credential/gitlab" + credLdap "github.com/hashicorp/vault/builtin/credential/ldap" + credOkta "github.com/hashicorp/vault/builtin/credential/okta" + credUserpass "github.com/hashicorp/vault/builtin/credential/userpass" +@@ -75,6 +76,7 @@ func newFullAddonHandlers() (map[string]physical.Factory, map[string]LoginHandle + "cf": &credCF.CLIHandler{}, + "gcp": &credGcp.CLIHandler{}, + "github": &credGitHub.CLIHandler{}, ++ "gitlab": &credGitlab.CLIHandler{}, + "kerberos": &credKerb.CLIHandler{}, + "ldap": &credLdap.CLIHandler{}, + "oci": &credOCI.CLIHandler{}, +diff --git a/helper/builtinplugins/registry_full.go b/helper/builtinplugins/registry_full.go +index 32bba40487..b4931436e2 100644 +--- a/helper/builtinplugins/registry_full.go ++++ b/helper/builtinplugins/registry_full.go +@@ -32,6 +32,7 @@ import ( + logicalTerraform "github.com/hashicorp/vault-plugin-secrets-terraform" + credAws "github.com/hashicorp/vault/builtin/credential/aws" + credGitHub "github.com/hashicorp/vault/builtin/credential/github" ++ credGitlab "github.com/hashicorp/vault/builtin/credential/gitlab" + credLdap "github.com/hashicorp/vault/builtin/credential/ldap" + credOkta "github.com/hashicorp/vault/builtin/credential/okta" + credRadius "github.com/hashicorp/vault/builtin/credential/radius" +@@ -64,6 +65,7 @@ func newFullAddonRegistry() *registry { + "cf": {Factory: credCF.Factory}, + "gcp": {Factory: credGcp.Factory}, + "github": {Factory: credGitHub.Factory}, ++ "gitlab": {Factory: credGitlab.Factory}, + "kerberos": {Factory: credKerb.Factory}, + "kubernetes": {Factory: credKube.Factory}, + "ldap": {Factory: credLdap.Factory}, +diff --git a/ui/app/adapters/auth-config/gitlab.js b/ui/app/adapters/auth-config/gitlab.js +new file mode 100644 +index 0000000000..21f5624ac4 +--- /dev/null ++++ b/ui/app/adapters/auth-config/gitlab.js +@@ -0,0 +1,2 @@ ++import AuthConfig from './_base'; ++export default AuthConfig.extend(); +diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js +index 5a8984ec33..dcc709f242 100644 +--- a/ui/app/adapters/cluster.js ++++ b/ui/app/adapters/cluster.js +@@ -180,6 +180,7 @@ export default ApplicationAdapter.extend({ + const authURLs = { + github: 'login', + jwt: 'login', ++ gitlab: username ? `login/${encodeURIComponent(username)}` : 'login', + oidc: 'login', + userpass: `login/${encodeURIComponent(username)}`, + ldap: `login/${encodeURIComponent(username)}`, +diff --git a/ui/app/components/auth-form.js b/ui/app/components/auth-form.js +index d87161b290..7d8307af7c 100644 +--- a/ui/app/components/auth-form.js ++++ b/ui/app/components/auth-form.js +@@ -185,8 +185,14 @@ export default Component.extend(DEFAULTS, { + this.set('selectedAuth', 'token'); + const adapter = this.store.adapterFor('tools'); + try { +- const response = yield adapter.toolAction('unwrap', null, { clientToken: token }); +- this.set('token', response.auth.client_token); ++ const response = yield adapter.toolAction('unwrap', null, {clientToken: token}); ++ if (response.data.authType) { ++ this.set('selectedAuth', response.data.authType) ++ this.set('token', response.data.token); ++ } else { ++ this.set('selectedAuth', 'token'); ++ this.set('token', response.auth.client_token); ++ } + this.send('doSubmit'); + } catch (e) { + this.set('error', `Token unwrap failed: ${e.errors[0]}`); +diff --git a/ui/app/helpers/mountable-auth-methods.js b/ui/app/helpers/mountable-auth-methods.js +index 53d2410d6c..0e0344fc59 100644 +--- a/ui/app/helpers/mountable-auth-methods.js ++++ b/ui/app/helpers/mountable-auth-methods.js +@@ -64,6 +64,13 @@ const MOUNTABLE_AUTH_METHODS = [ + category: 'cloud', + glyph: 'github-color', + }, ++ { ++ displayName: 'Gitlab', ++ value: 'gitlab', ++ type: 'gitlab', ++ glyph: 'auth', ++ category: 'cloud', ++ }, + { + displayName: 'JWT', + value: 'jwt', +diff --git a/ui/app/helpers/supported-auth-backends.js b/ui/app/helpers/supported-auth-backends.js +index 4c9185cf48..c75ca7fc0e 100644 +--- a/ui/app/helpers/supported-auth-backends.js ++++ b/ui/app/helpers/supported-auth-backends.js +@@ -76,6 +76,14 @@ const SUPPORTED_AUTH_BACKENDS = [ + displayNamePath: ['metadata.org', 'metadata.username'], + formAttributes: ['token'], + }, ++ { ++ type: 'gitlab', ++ typeDisplay: 'Gitlab', ++ description: 'Gitlab authentication.', ++ tokenPath: 'client_token', ++ displayNamePath: 'metadata.username', ++ formAttributes: ['token'], ++ }, + ]; + + const ENTERPRISE_AUTH_METHODS = [ +diff --git a/ui/app/helpers/tabs-for-auth-section.js b/ui/app/helpers/tabs-for-auth-section.js +index 77a1b92388..44f940d800 100644 +--- a/ui/app/helpers/tabs-for-auth-section.js ++++ b/ui/app/helpers/tabs-for-auth-section.js +@@ -39,6 +39,18 @@ const TABS_FOR_SETTINGS = { + routeParams: ['configuration'], + }, + ], ++ gitlab: [ ++ { ++ label: 'Configuration', ++ routeParams: ['vault.cluster.settings.auth.configure.section', 'configuration'], ++ }, ++ ], ++ gitlab: [ ++ { ++ label: 'Configuration', ++ routeParams: ['vault.cluster.settings.auth.configure.section', 'configuration'], ++ }, ++ ], + gcp: [ + { + label: 'Configuration', +diff --git a/ui/app/models/auth-config/gitlab.js b/ui/app/models/auth-config/gitlab.js +new file mode 100644 +index 0000000000..91d52cbcc3 +--- /dev/null ++++ b/ui/app/models/auth-config/gitlab.js +@@ -0,0 +1,39 @@ ++import { computed } from '@ember/object'; ++import DS from 'ember-data'; ++ ++import AuthConfig from '../auth-config'; ++import fieldToAttrs from 'vault/utils/field-to-attrs'; ++ ++const { attr } = DS; ++ ++export default AuthConfig.extend({ ++ baseURL: attr('string', { ++ label: 'Base URL', ++ }), ++ minAccessLevel: attr('string', { ++ label: 'Minimal Access Level', ++ defaultValue: 'developer', ++ possibleValues: ['none', 'guest', 'reporter', 'developer', 'maintainer', 'owner'] ++ }), ++ appID: attr('string', { ++ label: 'Oauth Application ID', ++ }), ++ appSecret: attr('string', { ++ label: 'Oauth Application Secret', ++ }), ++ callbackURL: attr('string', { ++ label: 'Oauth Callback URL', ++ }), ++ ciToken: attr('string', { ++ label: 'CI token', ++ }), ++ ++ fieldGroups: computed(function() { ++ const groups = [{ ++ 'Gitlab Options': ['baseURL', 'minAccessLevel', 'appID', 'appSecret', 'callbackURL', 'ciToken'], ++ }, ]; ++ ++ return fieldToAttrs(this, groups); ++ }), ++ ++}); +diff --git a/ui/app/routes/vault/cluster/settings/auth/configure/section.js b/ui/app/routes/vault/cluster/settings/auth/configure/section.js +index 0111f636ee..da91583f10 100644 +--- a/ui/app/routes/vault/cluster/settings/auth/configure/section.js ++++ b/ui/app/routes/vault/cluster/settings/auth/configure/section.js +@@ -23,6 +23,7 @@ export default Route.extend(UnloadModelRoute, { + 'aws-roletag-denylist': 'auth-config/aws/roletag-denylist', + 'azure-configuration': 'auth-config/azure', + 'github-configuration': 'auth-config/github', ++ 'gitlab-configuration': 'auth-config/gitlab', + 'gcp-configuration': 'auth-config/gcp', + 'jwt-configuration': 'auth-config/jwt', + 'oidc-configuration': 'auth-config/oidc', +diff --git a/ui/app/templates/components/auth-form.hbs b/ui/app/templates/components/auth-form.hbs +index f9fee4c71f..8010db7b6f 100644 +--- a/ui/app/templates/components/auth-form.hbs ++++ b/ui/app/templates/components/auth-form.hbs +@@ -118,6 +118,19 @@ + /> + + ++ {{else if (eq this.providerName "gitlab")}} ++
++ ++ ++ ++
++ ++
++ ++
++
+ {{else if (eq this.providerName "token")}} +
+ +diff --git a/ui/app/templates/components/wizard/gitlab-method.hbs b/ui/app/templates/components/wizard/gitlab-method.hbs +new file mode 100644 +index 0000000000..f3d95a1762 +--- /dev/null ++++ b/ui/app/templates/components/wizard/gitlab-method.hbs +@@ -0,0 +1,10 @@ ++ ++

++ The Gitlab auth method can be used to authenticate with Vault using a Gitlab access token. ++

++
+diff --git a/ui/public/eco/gitlab.svg b/ui/public/eco/gitlab.svg +new file mode 100644 +index 0000000000..95a22f1017 +--- /dev/null ++++ b/ui/public/eco/gitlab.svg +@@ -0,0 +1 @@ ++ +\ No newline at end of file +diff --git a/ui/tests/acceptance/settings/auth/configure/section-test.js b/ui/tests/acceptance/settings/auth/configure/section-test.js +index b6533273c0..f0f968f666 100644 +--- a/ui/tests/acceptance/settings/auth/configure/section-test.js ++++ b/ui/tests/acceptance/settings/auth/configure/section-test.js +@@ -56,7 +56,7 @@ module('Acceptance | settings/auth/configure/section', function (hooks) { + ); + }); + +- for (const type of ['aws', 'azure', 'gcp', 'github', 'kubernetes']) { ++ for (const type of ['aws', 'azure', 'gcp', 'github', 'gitlab', 'kubernetes']) { + test(`it shows tabs for auth method: ${type}`, async function (assert) { + const path = `${type}-showtab-${this.uid}`; + await cli.toggle();